style: auto-format codebase with Prettier

Applied formatting to 1487+ files using pnpm format:write
  - TypeScript/JavaScript files
  - Svelte components
  - Astro pages
  - JSON configs
  - Markdown docs

  13 files still need manual review (Astro JSX comments)
This commit is contained in:
Wuesteon 2025-11-27 18:33:16 +01:00
parent 0241f5554c
commit d36b321d9d
3952 changed files with 661498 additions and 739751 deletions

View file

@ -10,11 +10,11 @@ Go to `https://github.com/Memo-2023/manadeck` → Settings → Secrets and varia
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 | `manadeck-backend-sa@memo-2c4c4.iam.gserviceaccount.com` |
| `GH_PERSONAL_TOKEN` | GitHub Personal Access Token for private packages | See "Create GitHub PAT" below |
| 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 | `manadeck-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
@ -110,6 +110,7 @@ done
```
**IMPORTANT**: Add the generated `SERVICE_KEY` to mana-core-middleware's `APP_SERVICE_KEYS`:
```
APP_SERVICE_KEYS=existing-apps,YOUR_APP_ID:YOUR_SERVICE_KEY
```
@ -119,6 +120,7 @@ APP_SERVICE_KEYS=existing-apps,YOUR_APP_ID:YOUR_SERVICE_KEY
### Automatic Deployment (GitHub Actions)
1. Push to `main` branch:
```bash
git add .
git commit -m "feat: your changes"
@ -199,6 +201,7 @@ gcloud logging read "resource.type=cloud_run_revision AND resource.labels.servic
**Problem**: Service account can't access secrets in `mana-core-453821`
**Solution**: Grant cross-project secret access:
```bash
SA_EMAIL="manadeck-backend-sa@memo-2c4c4.iam.gserviceaccount.com"
@ -213,11 +216,13 @@ gcloud secrets add-iam-policy-binding MANADECK_APP_ID \
**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 Core
**Debug**:
```bash
# Check service logs
gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=manadeck-backend" \
@ -233,6 +238,7 @@ gcloud secrets versions access latest --secret=MANADECK_APP_ID --project=mana-co
**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
```
@ -261,19 +267,20 @@ manadeck/
## 📝 Configuration Summary
| Component | Location | Value |
|-----------|----------|-------|
| **Deployment Project** | GCP | `memo-2c4c4` |
| **Secrets Project** | GCP | `mana-core-453821` |
| **Region** | GCP | `europe-west3` |
| **Service Name** | Cloud Run | `manadeck-backend` |
| **Image Registry** | Artifact Registry | `europe-west3-docker.pkg.dev/memo-2c4c4/manadeck-backend` |
| **Port** | Container | `8080` |
| **Repository** | GitHub | `Memo-2023/manadeck` |
| Component | Location | Value |
| ---------------------- | ----------------- | --------------------------------------------------------- |
| **Deployment Project** | GCP | `memo-2c4c4` |
| **Secrets Project** | GCP | `mana-core-453821` |
| **Region** | GCP | `europe-west3` |
| **Service Name** | Cloud Run | `manadeck-backend` |
| **Image Registry** | Artifact Registry | `europe-west3-docker.pkg.dev/memo-2c4c4/manadeck-backend` |
| **Port** | Container | `8080` |
| **Repository** | GitHub | `Memo-2023/manadeck` |
## 🎯 Quick Start
**First-time setup**:
```bash
# 1. Create GCP resources
./backend/create-secrets.sh
@ -285,6 +292,7 @@ git push origin main
```
**After setup**:
```bash
# Just push to deploy
git add .

View file

@ -1,98 +1,102 @@
# 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 '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";
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'
'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 });
}
// 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');
}
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 ', '');
// 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');
}
// 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);
// 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');
}
const userId = payload.sub as string;
if (!userId) {
throw new Error('Invalid token: no user ID');
}
console.log(`Authenticated user: ${userId}`);
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);
// 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 };
// 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;
// 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');
}
// 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
});
}
// 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,
}
);
}
});
```

View file

@ -3,12 +3,14 @@
## Overview
This guide covers deploying the Manadeck backend service to Google Cloud Run. The service can be deployed either:
- **Automatically** via GitHub Actions (recommended)
- **Manually** via Cloud Build and gcloud CLI
## Prerequisites
1. **Google Cloud SDK** installed and authenticated:
```bash
gcloud auth login
gcloud config set project memo-2c4c4
@ -97,7 +99,9 @@ done
Go to your repository → Settings → Secrets and variables → Actions
Add these secrets:
- **GCP_SA_KEY_PROD**: Create and download a service account key:
```bash
gcloud iam service-accounts keys create key.json \
--iam-account=manadeck-backend-sa@memo-2c4c4.iam.gserviceaccount.com \
@ -114,11 +118,13 @@ Add these secrets:
### Trigger Deployment
The GitHub Actions workflow automatically deploys when:
- Code is pushed to `main` branch
- Changes are made to `manadeck/backend/**` directory
- Workflow file is modified
**Manual trigger:**
1. Go to GitHub → Actions tab
2. Select "Deploy Manadeck Backend to Cloud Run"
3. Click "Run workflow"
@ -237,12 +243,14 @@ curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
To deploy a new version:
1. **Edit `cloudbuild.yaml`**:
```yaml
# Change v1.0.0 to v1.1.0
- 'europe-west3-docker.pkg.dev/memo-2c4c4/manadeck-backend/manadeck-backend:v1.1.0'
```
2. **Build and deploy**:
```bash
gcloud builds submit --project=memo-2c4c4 --config=cloudbuild.yaml .
@ -284,18 +292,21 @@ gcloud run services update-traffic manadeck-backend \
### Build Fails
**Authentication errors:**
```bash
gcloud auth login
gcloud auth configure-docker europe-west3-docker.pkg.dev
```
**Permission denied:**
- Verify you have Cloud Build Editor role
- Check service account has Artifact Registry Writer role
### Deployment Fails
**Secret not found:**
```bash
# List secrets
gcloud secrets list --project=memo-2c4c4
@ -305,6 +316,7 @@ echo "value" | gcloud secrets create SECRET_NAME --data-file=- --project=memo-2c
```
**Service account permissions:**
```bash
# Check service account IAM policy
gcloud projects get-iam-policy memo-2c4c4 \
@ -315,6 +327,7 @@ gcloud projects get-iam-policy memo-2c4c4 \
### Health Check Fails
**Check logs:**
```bash
gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=manadeck-backend" \
--project=memo-2c4c4 \
@ -323,12 +336,14 @@ gcloud logging read "resource.type=cloud_run_revision AND resource.labels.servic
```
**Common issues:**
- Port mismatch (must be 8080)
- Missing environment variables
- Database connection issues
- Mana Core service unreachable
**Test locally:**
```bash
cd manadeck/backend
@ -399,15 +414,15 @@ gcloud logging read "resource.type=cloud_run_revision AND resource.labels.servic
### Environment Variables (via Secrets)
| Secret Name | Description | Example | Note |
|------------|-------------|---------|------|
| MANA_SERVICE_URL | Mana Core service URL | https://mana-core.example.com | Global secret |
| MANADECK_APP_ID | Application ID from Mana | app-12345 | |
| MANADECK_SERVICE_KEY | Service authentication key | sk_live_... | |
| MANADECK_SUPABASE_URL | Supabase project URL | https://abc.supabase.co | |
| MANADECK_SUPABASE_ANON_KEY | Supabase anonymous key | eyJhb... | |
| MANADECK_SUPABASE_SERVICE_KEY | Supabase service role key | eyJhb... | |
| MANADECK_SIGNUP_REDIRECT_URL | Post-signup redirect URL | https://app.example.com/welcome | |
| Secret Name | Description | Example | Note |
| ----------------------------- | -------------------------- | ------------------------------- | ------------- |
| MANA_SERVICE_URL | Mana Core service URL | https://mana-core.example.com | Global secret |
| MANADECK_APP_ID | Application ID from Mana | app-12345 | |
| MANADECK_SERVICE_KEY | Service authentication key | sk*live*... | |
| MANADECK_SUPABASE_URL | Supabase project URL | https://abc.supabase.co | |
| MANADECK_SUPABASE_ANON_KEY | Supabase anonymous key | eyJhb... | |
| MANADECK_SUPABASE_SERVICE_KEY | Supabase service role key | eyJhb... | |
| MANADECK_SIGNUP_REDIRECT_URL | Post-signup redirect URL | https://app.example.com/welcome | |
### Cloud Run Configuration
@ -473,6 +488,7 @@ echo "new-value" | gcloud secrets versions add MANA_SERVICE_URL \
## Support
For issues or questions:
1. Check Cloud Run logs for error messages
2. Verify all secrets are configured correctly
3. Test health endpoints

View file

@ -99,6 +99,7 @@ FRONTEND_URL=http://localhost:8081
## Available Endpoints
### Authentication (Proxied by ManaCoreModule)
- `POST /auth/signin` - Email/password sign-in
- `POST /auth/signup` - User registration
- `POST /auth/google-signin` - Google OAuth
@ -111,6 +112,7 @@ FRONTEND_URL=http://localhost:8081
- `GET /auth/devices` - Get user devices
### Protected API Endpoints (Requires Authentication)
- `GET /v1/api/profile` - Get user profile
- `GET /v1/api/decks` - Get user's decks
- `POST /v1/api/decks` - Create a new deck
@ -121,6 +123,7 @@ FRONTEND_URL=http://localhost:8081
- `GET /v1/api/stats` - Get user statistics
### Public Endpoints
- `GET /v1/public/featured-decks` - Get featured decks (personalized if authenticated)
- `GET /v1/public/leaderboard` - Get leaderboard
- `GET /v1/public/deck-templates` - Get deck templates
@ -129,6 +132,7 @@ FRONTEND_URL=http://localhost:8081
- `GET /v1/public/version` - API version info
### Health Checks
- `GET /health` - Comprehensive health check
- `GET /health/ready` - Readiness probe
- `GET /health/live` - Liveness probe
@ -155,27 +159,32 @@ npm run build
## Testing the Integration
1. **Start Mana Core Middleware** (if running locally):
```bash
cd ../mana-core-middleware
npm run start:dev
```
2. **Start the Backend**:
```bash
npm run start:dev
```
3. **Test Health Check**:
```bash
curl http://localhost:8080/health
```
4. **Test Public Endpoint**:
```bash
curl http://localhost:8080/v1/public/version
```
5. **Test Authentication**:
```bash
# Sign up
curl -X POST http://localhost:8080/auth/signup \
@ -189,6 +198,7 @@ curl -X POST http://localhost:8080/auth/signin \
```
6. **Test Protected Endpoint**:
```bash
# Use the token from signin response
curl http://localhost:8080/v1/api/profile \
@ -198,11 +208,13 @@ curl http://localhost:8080/v1/api/profile \
## Docker
### Build Docker Image
```bash
docker build -t manadeck-backend .
```
### Run Docker Container
```bash
docker run -p 8080:8080 --env-file .env manadeck-backend
```
@ -212,11 +224,13 @@ docker run -p 8080:8080 --env-file .env manadeck-backend
### Google Cloud Run
1. **Build and push to Container Registry**:
```bash
gcloud builds submit --config cloudbuild.yaml
```
2. **Or deploy directly**:
```bash
gcloud run deploy manadeck-backend \
--source . \
@ -229,28 +243,31 @@ gcloud run deploy manadeck-backend \
Update your React Native app to use the backend:
1. **Update API configuration**:
```javascript
// utils/api.js
const API_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:8080';
```
2. **Use auth endpoints**:
```javascript
// Sign in
const response = await fetch(`${API_URL}/auth/signin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
```
3. **Use protected endpoints**:
```javascript
// Get profile
const response = await fetch(`${API_URL}/v1/api/profile`, {
headers: {
'Authorization': `Bearer ${token}`
},
headers: {
Authorization: `Bearer ${token}`,
},
});
```
@ -267,18 +284,22 @@ const response = await fetch(`${API_URL}/v1/api/profile`, {
## Troubleshooting
### "App verification failed"
- Verify APP_ID matches the one registered with Mana Core
- Check MANA_SERVICE_URL is correct and accessible
### "Token expired" errors
- Ensure token refresh is implemented in the mobile app
- Check token expiration settings in Mana Core
### "CORS errors" in mobile app
- Update FRONTEND_URL in .env
- Verify CORS configuration in main.ts
### "Cannot connect to Mana Core"
- Ensure Mana Core Middleware is running
- Check network connectivity
- Verify MANA_SERVICE_URL is correct
@ -295,6 +316,7 @@ const response = await fetch(`${API_URL}/v1/api/profile`, {
## Support
For issues or questions:
- Check the Mana Core documentation
- Review the integration guides
- Contact the development team
- Contact the development team

View file

@ -3,6 +3,7 @@
## TL;DR
**Two-layer approach:**
1. **CI test stage**: Patch lockfile SSH → HTTPS for `npm ci` (for tests)
2. **Docker build**: Clone private repo, build tarball, replace with `file:` (for production image)
@ -13,14 +14,17 @@ Your local machine converts HTTPS → SSH during `npm install`, baking SSH URLs
## Why Fighting It Locally Doesn't Work
**Approach 1**: "Fix package.json to use HTTPS"
- Doesn't work if git config rewrites it during install
**Approach 2**: "Remove SSH rewrites from git config"
- Inconvenient for developers
- Easy to forget
- Breaks other workflows
**Approach 3**: "Temporarily disable git config during install"
- Doesn't persist
- Every developer needs to remember
@ -36,7 +40,7 @@ The GitHub Actions workflow uses the proven pattern to handle both SSH and HTTPS
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false # Don't let default GITHUB_TOKEN interfere
persist-credentials: false # Don't let default GITHUB_TOKEN interfere
- name: Configure git for private packages
env:
@ -148,11 +152,11 @@ sed -i 's|"git+https://..."|"file:mana-core.tgz"|g' package.json
## Why This is Better Than Alternatives
| Approach | Developer Impact | Reliability | Production Quality | Maintenance |
|----------|-----------------|-------------|-------------------|-------------|
| Fix git config locally | 😡 High | 🔴 Low | ⚠️ Medium | 😱 High |
| Require HTTPS in lockfile | 😡 High | 🔴 Low | ⚠️ Medium | 😱 High |
| **Two-layer (sed + tarball)** | 😊 None | 🟢 100% | ✅ Excellent | 😌 None |
| Approach | Developer Impact | Reliability | Production Quality | Maintenance |
| ----------------------------- | ---------------- | ----------- | ------------------ | ----------- |
| Fix git config locally | 😡 High | 🔴 Low | ⚠️ Medium | 😱 High |
| Require HTTPS in lockfile | 😡 High | 🔴 Low | ⚠️ Medium | 😱 High |
| **Two-layer (sed + tarball)** | 😊 None | 🟢 100% | ✅ Excellent | 😌 None |
## Lessons Learned
@ -167,6 +171,7 @@ sed -i 's|"git+https://..."|"file:mana-core.tgz"|g' package.json
## References
This solution combines proven approaches:
- **sed patching**: Used in `storyteller-project` for CI/CD
- **tarball approach**: Used in memoro-service for production Docker images
- Battle-tested across multiple projects

View file

@ -1,13 +1,13 @@
{
"type": "service_account",
"project_id": "memo-2c4c4",
"private_key_id": "891033d18bb7a58f26590d7289aa10f171709511",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDDlzSIuERbKHd4\nrTOgW3A3AiQ4oaluDWFrxXvMKOXeKsqMx2LZr3LjaG1VohR11/m3yNCrENwWwMil\nKlTzJridVZgxskpsnftj5LZiMIWTkGrbEKrR7t4EFGX7B1PlDByoHkg70UWSv4Cp\nLEqrKNc9GDa+HJuCpAu5Y8s5Vr1jcn4pNYcbe8Qz98M05u5SjsRi3Wo4sApQtMO+\n1I43+BBkQgP53rvbTGmvDkJPX+X30+pHrWIGq1RBbY99T39EfxgVfYbxKIkGTiAU\nT97I2L8A+AcTOZuYpeLVUYkpWyb/PhXRaBzZZpX4DuVl7rex3B903SqXEAmvXGUF\n08YtW3gHAgMBAAECggEAKHjMRRIkCtzXw9/LE/53vNozlohVH9Gi/AL0N+2qiws9\nuF21pae5VTcRkKumIjBbmtzx2MP3VrnzisG0rE+x2dSRVo82+TzDfQshdjXmHf+/\nLK9JOYF87UkvRP9iK74S2/LQLZiDFfeMbvUcSkh8KUcuZK5Uu3aoemcc8TNyovX8\na/rB1LqhMMh6e/5dm/na1iTAigonhlZl9xja4vinsa4k9wMwJrIW+eOpe5lA5Kd1\n9Y2iz4PaORNczUUeozJ820Y27S7WfvvbEGwXJgMO6fkkB+FGgVWl+BxY/BzATk6t\naeimdBWUNmRoqTAGND13hmVytEP0aBhM9bd/FSMOOQKBgQDgmAhyeWn6GkKMNDec\nB9bIWxW7P6ArdAHEnJQBWVStXYI01rS4T+Tet+D8w4Id4Z0d23UfVgMcF6E36xBG\nTpVYHlj30CfFXnkhiLxP2wx/i3ogNj8jrGU2yYKaE/72GzPx1YFHXXbTBdqrQRR+\nSY2ZgLyEpH0k6+H1bNIb7p1CswKBgQDe8OuUJHyQI2Elxv9eyE2TG73RA2TUCTH/\n9H7/ddtiYNFQv5m1u6a2tJomqFa9iTD0KPKJ1k43M63WjKRLlj0c1EFrVFEh7xuA\nYWtuuL9pl+LIskChI+HYBEn+4BejZs41kSDgOwQC+sztc52x77QbwB69XHvjTx8y\n+y49LUdPXQKBgH2JfW8/HlWFyBzGMy8r0oo7H3KCpeyj8zkKrE/0trgQxG70dB9x\nyOQUs9WVEm3RZhkCSf0x/pr9OkgfRAGIe0G99MzPtOtFWz+7Rkb8kYfZVnIa4D/4\nZLqy+CVXd/PoUKikRpa1T220n/2JawML6fIbW0RpDOpOTwb3xJmITq6xAoGAQnas\npMOj5wzFH5AN/eHVTrAMjM1oN9Zt85KeceZe+xtGC76zQnypHPsGodO52FOYJpI2\njGU5eDeN8dKIAIjPu1KEFk1Syr0QA9wHPUESv5YqZA7QOboK0G2m6bdBFNPHUizs\nEi1F6uV+RqQbNTrzBPrnryhtPr0N3hMIG/Dtu3kCgYEA1e2BtMIGnnM7Cm7fXfVp\nMeP5zBiqPcsUXVAoJ2WaYPj8n/BNhTd79STWZCUlTfcZ1IywIP9yMhleIXfKW35v\nNVFNMcduCXiqExWqmyjnVZJPFawO+KQ4G+JO8FzH6S/O82PUNbTRjJDs87cJs8t3\nSnd0TEt4+T9vAcdei3Kor20=\n-----END PRIVATE KEY-----\n",
"client_email": "manadeck-backend-sa@memo-2c4c4.iam.gserviceaccount.com",
"client_id": "101484948835153964567",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/manadeck-backend-sa%40memo-2c4c4.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
"type": "service_account",
"project_id": "memo-2c4c4",
"private_key_id": "891033d18bb7a58f26590d7289aa10f171709511",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDDlzSIuERbKHd4\nrTOgW3A3AiQ4oaluDWFrxXvMKOXeKsqMx2LZr3LjaG1VohR11/m3yNCrENwWwMil\nKlTzJridVZgxskpsnftj5LZiMIWTkGrbEKrR7t4EFGX7B1PlDByoHkg70UWSv4Cp\nLEqrKNc9GDa+HJuCpAu5Y8s5Vr1jcn4pNYcbe8Qz98M05u5SjsRi3Wo4sApQtMO+\n1I43+BBkQgP53rvbTGmvDkJPX+X30+pHrWIGq1RBbY99T39EfxgVfYbxKIkGTiAU\nT97I2L8A+AcTOZuYpeLVUYkpWyb/PhXRaBzZZpX4DuVl7rex3B903SqXEAmvXGUF\n08YtW3gHAgMBAAECggEAKHjMRRIkCtzXw9/LE/53vNozlohVH9Gi/AL0N+2qiws9\nuF21pae5VTcRkKumIjBbmtzx2MP3VrnzisG0rE+x2dSRVo82+TzDfQshdjXmHf+/\nLK9JOYF87UkvRP9iK74S2/LQLZiDFfeMbvUcSkh8KUcuZK5Uu3aoemcc8TNyovX8\na/rB1LqhMMh6e/5dm/na1iTAigonhlZl9xja4vinsa4k9wMwJrIW+eOpe5lA5Kd1\n9Y2iz4PaORNczUUeozJ820Y27S7WfvvbEGwXJgMO6fkkB+FGgVWl+BxY/BzATk6t\naeimdBWUNmRoqTAGND13hmVytEP0aBhM9bd/FSMOOQKBgQDgmAhyeWn6GkKMNDec\nB9bIWxW7P6ArdAHEnJQBWVStXYI01rS4T+Tet+D8w4Id4Z0d23UfVgMcF6E36xBG\nTpVYHlj30CfFXnkhiLxP2wx/i3ogNj8jrGU2yYKaE/72GzPx1YFHXXbTBdqrQRR+\nSY2ZgLyEpH0k6+H1bNIb7p1CswKBgQDe8OuUJHyQI2Elxv9eyE2TG73RA2TUCTH/\n9H7/ddtiYNFQv5m1u6a2tJomqFa9iTD0KPKJ1k43M63WjKRLlj0c1EFrVFEh7xuA\nYWtuuL9pl+LIskChI+HYBEn+4BejZs41kSDgOwQC+sztc52x77QbwB69XHvjTx8y\n+y49LUdPXQKBgH2JfW8/HlWFyBzGMy8r0oo7H3KCpeyj8zkKrE/0trgQxG70dB9x\nyOQUs9WVEm3RZhkCSf0x/pr9OkgfRAGIe0G99MzPtOtFWz+7Rkb8kYfZVnIa4D/4\nZLqy+CVXd/PoUKikRpa1T220n/2JawML6fIbW0RpDOpOTwb3xJmITq6xAoGAQnas\npMOj5wzFH5AN/eHVTrAMjM1oN9Zt85KeceZe+xtGC76zQnypHPsGodO52FOYJpI2\njGU5eDeN8dKIAIjPu1KEFk1Syr0QA9wHPUESv5YqZA7QOboK0G2m6bdBFNPHUizs\nEi1F6uV+RqQbNTrzBPrnryhtPr0N3hMIG/Dtu3kCgYEA1e2BtMIGnnM7Cm7fXfVp\nMeP5zBiqPcsUXVAoJ2WaYPj8n/BNhTd79STWZCUlTfcZ1IywIP9yMhleIXfKW35v\nNVFNMcduCXiqExWqmyjnVZJPFawO+KQ4G+JO8FzH6S/O82PUNbTRjJDs87cJs8t3\nSnd0TEt4+T9vAcdei3Kor20=\n-----END PRIVATE KEY-----\n",
"client_email": "manadeck-backend-sa@memo-2c4c4.iam.gserviceaccount.com",
"client_id": "101484948835153964567",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/manadeck-backend-sa%40memo-2c4c4.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View file

@ -1,8 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -1,83 +1,83 @@
{
"name": "@manadeck/backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@manacore/shared-errors": "workspace:*",
"@google/genai": "^1.14.0",
"@manacore/manadeck-database": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/terminus": "^11.0.0",
"axios": "^1.7.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"joi": "^18.0.1",
"nestjs-cls": "^6.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
"name": "@manadeck/backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@manacore/shared-errors": "workspace:*",
"@google/genai": "^1.14.0",
"@manacore/manadeck-database": "workspace:*",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/terminus": "^11.0.0",
"axios": "^1.7.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"joi": "^18.0.1",
"nestjs-cls": "^6.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View file

@ -3,20 +3,20 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View file

@ -3,10 +3,10 @@ import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View file

@ -12,74 +12,75 @@ import { HealthController } from './controllers/health.controller';
import { validationSchema } from './config/validation.schema';
import { AiService } from './services/ai.service';
import {
DatabaseModule,
DeckRepository,
CardRepository,
UserStatsRepository,
DeckTemplateRepository,
StudySessionRepository,
CardProgressRepository,
DatabaseModule,
DeckRepository,
CardRepository,
UserStatsRepository,
DeckTemplateRepository,
StudySessionRepository,
CardProgressRepository,
} from './database';
@Module({
imports: [
// Context storage for request-scoped data (must be first)
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
generateId: true,
},
}),
imports: [
// Context storage for request-scoped data (must be first)
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
generateId: true,
},
}),
// Configuration management
ConfigModule.forRoot({
isGlobal: true,
validationSchema,
ignoreEnvFile: process.env.NODE_ENV === 'production',
}),
// Configuration management
ConfigModule.forRoot({
isGlobal: true,
validationSchema,
ignoreEnvFile: process.env.NODE_ENV === 'production',
}),
// Mana Core authentication
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
manaServiceUrl: configService.get<string>('MANA_SERVICE_URL', 'https://mana-core-middleware-111768794939.europe-west3.run.app'),
appId: configService.get<string>('APP_ID', 'cea4bfc6-a4de-4e17-91e2-54275940156e'),
serviceKey: configService.get<string>('MANA_SUPABASE_SECRET_KEY', ''), // Required for service-to-service communication
signupRedirectUrl: configService.get<string>('SIGNUP_REDIRECT_URL', 'https://manadeck.com/welcome'),
debug: configService.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}) as any,
// Mana Core authentication
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
manaServiceUrl: configService.get<string>(
'MANA_SERVICE_URL',
'https://mana-core-middleware-111768794939.europe-west3.run.app'
),
appId: configService.get<string>('APP_ID', 'cea4bfc6-a4de-4e17-91e2-54275940156e'),
serviceKey: configService.get<string>('MANA_SUPABASE_SECRET_KEY', ''), // Required for service-to-service communication
signupRedirectUrl: configService.get<string>(
'SIGNUP_REDIRECT_URL',
'https://manadeck.com/welcome'
),
debug: configService.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],
}) as any,
// Health checks
TerminusModule,
HttpModule,
// Health checks
TerminusModule,
HttpModule,
// Database (Drizzle/PostgreSQL)
DatabaseModule,
],
controllers: [
AppController,
ApiController,
PublicController,
HealthController,
],
providers: [
AppService,
// AI Service
AiService,
// Database repositories
DeckRepository,
CardRepository,
UserStatsRepository,
DeckTemplateRepository,
StudySessionRepository,
CardProgressRepository,
],
// Database (Drizzle/PostgreSQL)
DatabaseModule,
],
controllers: [AppController, ApiController, PublicController, HealthController],
providers: [
AppService,
// AI Service
AiService,
// Database repositories
DeckRepository,
CardRepository,
UserStatsRepository,
DeckTemplateRepository,
StudySessionRepository,
CardProgressRepository,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Add any custom middleware here
}
configure(consumer: MiddlewareConsumer) {
// Add any custom middleware here
}
}

View file

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
getHello(): string {
return 'Hello World!';
}
}

View file

@ -6,46 +6,46 @@
*/
export enum CreditOperationType {
DECK_CREATION = 'deck_creation',
CARD_CREATION = 'card_creation',
AI_CARD_GENERATION = 'ai_card_generation',
AI_DECK_GENERATION = 'ai_deck_generation',
DECK_EXPORT = 'deck_export',
// Add more operation types as needed
DECK_CREATION = 'deck_creation',
CARD_CREATION = 'card_creation',
AI_CARD_GENERATION = 'ai_card_generation',
AI_DECK_GENERATION = 'ai_deck_generation',
DECK_EXPORT = 'deck_export',
// Add more operation types as needed
}
/**
* Credit costs for each operation type
*/
export const CREDIT_COSTS: Record<CreditOperationType, number> = {
[CreditOperationType.DECK_CREATION]: 10, // 10 mana to create a deck
[CreditOperationType.CARD_CREATION]: 2, // 2 mana to create a card
[CreditOperationType.AI_CARD_GENERATION]: 5, // 5 mana for AI-generated card
[CreditOperationType.AI_DECK_GENERATION]: 30, // 30 mana for AI-generated deck
[CreditOperationType.DECK_EXPORT]: 3, // 3 mana to export deck
[CreditOperationType.DECK_CREATION]: 10, // 10 mana to create a deck
[CreditOperationType.CARD_CREATION]: 2, // 2 mana to create a card
[CreditOperationType.AI_CARD_GENERATION]: 5, // 5 mana for AI-generated card
[CreditOperationType.AI_DECK_GENERATION]: 30, // 30 mana for AI-generated deck
[CreditOperationType.DECK_EXPORT]: 3, // 3 mana to export deck
};
/**
* Operation descriptions for user-facing messages
*/
export const OPERATION_DESCRIPTIONS: Record<CreditOperationType, string> = {
[CreditOperationType.DECK_CREATION]: 'Create a new deck',
[CreditOperationType.CARD_CREATION]: 'Add a card to your deck',
[CreditOperationType.AI_CARD_GENERATION]: 'Generate card with AI',
[CreditOperationType.AI_DECK_GENERATION]: 'Generate deck with AI',
[CreditOperationType.DECK_EXPORT]: 'Export deck',
[CreditOperationType.DECK_CREATION]: 'Create a new deck',
[CreditOperationType.CARD_CREATION]: 'Add a card to your deck',
[CreditOperationType.AI_CARD_GENERATION]: 'Generate card with AI',
[CreditOperationType.AI_DECK_GENERATION]: 'Generate deck with AI',
[CreditOperationType.DECK_EXPORT]: 'Export deck',
};
/**
* Get credit cost for an operation
*/
export function getCreditCost(operationType: CreditOperationType): number {
return CREDIT_COSTS[operationType] || 0;
return CREDIT_COSTS[operationType] || 0;
}
/**
* Get operation description
*/
export function getOperationDescription(operationType: CreditOperationType): string {
return OPERATION_DESCRIPTIONS[operationType] || operationType;
return OPERATION_DESCRIPTIONS[operationType] || operationType;
}

View file

@ -1,26 +1,24 @@
import * as Joi from 'joi';
export const validationSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
PORT: Joi.number().default(8080),
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
PORT: Joi.number().default(8080),
// Mana Core Required
MANA_SERVICE_URL: Joi.string().required(),
APP_ID: Joi.string().uuid().required(),
MANA_SUPABASE_SECRET_KEY: Joi.string().optional(),
SIGNUP_REDIRECT_URL: Joi.string().uri().optional(),
// Mana Core Required
MANA_SERVICE_URL: Joi.string().required(),
APP_ID: Joi.string().uuid().required(),
MANA_SUPABASE_SECRET_KEY: Joi.string().optional(),
SIGNUP_REDIRECT_URL: Joi.string().uri().optional(),
// PostgreSQL Database
DATABASE_URL: Joi.string().required(),
// PostgreSQL Database
DATABASE_URL: Joi.string().required(),
// JWT
JWT_SECRET: Joi.string().optional(),
// JWT
JWT_SECRET: Joi.string().optional(),
// CORS
FRONTEND_URL: Joi.string().uri().optional(),
// CORS
FRONTEND_URL: Joi.string().uri().optional(),
// AI Services
GOOGLE_GENAI_API_KEY: Joi.string().optional(),
});
// AI Services
GOOGLE_GENAI_API_KEY: Joi.string().optional(),
});

File diff suppressed because it is too large Load diff

View file

@ -1,54 +1,50 @@
import { Controller, Get } from '@nestjs/common';
import {
HealthCheckService,
HttpHealthIndicator,
HealthCheck,
} from '@nestjs/terminus';
import { HealthCheckService, HttpHealthIndicator, HealthCheck } from '@nestjs/terminus';
import { ConfigService } from '@nestjs/config';
import { Public } from '@mana-core/nestjs-integration/decorators';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
private configService: ConfigService,
) {}
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
private configService: ConfigService
) {}
@Public()
@Get()
@HealthCheck()
check() {
const manaServiceUrl = this.configService.get<string>('MANA_SERVICE_URL')!;
@Public()
@Get()
@HealthCheck()
check() {
const manaServiceUrl = this.configService.get<string>('MANA_SERVICE_URL')!;
return this.health.check([
() => this.http.pingCheck('mana-core', manaServiceUrl),
// PostgreSQL health is checked via database module initialization
]);
}
return this.health.check([
() => this.http.pingCheck('mana-core', manaServiceUrl),
// PostgreSQL health is checked via database module initialization
]);
}
@Public()
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => ({
ready: {
status: 'up',
message: 'Service is ready to receive requests',
},
}),
]);
}
@Public()
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => ({
ready: {
status: 'up',
message: 'Service is ready to receive requests',
},
}),
]);
}
@Public()
@Get('live')
liveness() {
return {
status: 'ok',
timestamp: new Date(),
uptime: process.uptime(),
environment: this.configService.get<string>('NODE_ENV'),
};
}
}
@Public()
@Get('live')
liveness() {
return {
status: 'ok',
timestamp: new Date(),
uptime: process.uptime(),
environment: this.configService.get<string>('NODE_ENV'),
};
}
}

View file

@ -5,102 +5,102 @@ import { DeckRepository, UserStatsRepository, DeckTemplateRepository } from '../
@Controller('public')
export class PublicController {
private readonly logger = new Logger(PublicController.name);
private readonly logger = new Logger(PublicController.name);
constructor(
private readonly deckRepository: DeckRepository,
private readonly userStatsRepository: UserStatsRepository,
private readonly deckTemplateRepository: DeckTemplateRepository,
) {}
constructor(
private readonly deckRepository: DeckRepository,
private readonly userStatsRepository: UserStatsRepository,
private readonly deckTemplateRepository: DeckTemplateRepository
) {}
@Public()
@Get('health')
health() {
return { status: 'ok', timestamp: new Date() };
}
@Public()
@Get('health')
health() {
return { status: 'ok', timestamp: new Date() };
}
@Public()
@Get('version')
version() {
return {
version: '1.0.0',
service: 'manadeck-backend',
environment: process.env.NODE_ENV,
};
}
@Public()
@Get('version')
version() {
return {
version: '1.0.0',
service: 'manadeck-backend',
environment: process.env.NODE_ENV,
};
}
@UseGuards(OptionalAuthGuard)
@Get('featured-decks')
async getFeaturedDecks(@CurrentUser() user?: any) {
const decks = await this.deckRepository.findFeatured(10);
@UseGuards(OptionalAuthGuard)
@Get('featured-decks')
async getFeaturedDecks(@CurrentUser() user?: any) {
const decks = await this.deckRepository.findFeatured(10);
if (user) {
this.logger.log(`Getting personalized featured decks for user: ${user.sub}`);
return {
type: 'personalized',
userId: user.sub,
decks,
count: decks.length,
};
}
if (user) {
this.logger.log(`Getting personalized featured decks for user: ${user.sub}`);
return {
type: 'personalized',
userId: user.sub,
decks,
count: decks.length,
};
}
this.logger.log('Getting generic featured decks');
return {
type: 'generic',
decks,
count: decks.length,
};
}
this.logger.log('Getting generic featured decks');
return {
type: 'generic',
decks,
count: decks.length,
};
}
@UseGuards(OptionalAuthGuard)
@Get('leaderboard')
async getLeaderboard(@CurrentUser() user?: any, @Query('limit') limit = '10') {
const limitNum = Math.min(parseInt(limit, 10) || 10, 100);
const leaderboard = await this.userStatsRepository.getLeaderboard(limitNum);
@UseGuards(OptionalAuthGuard)
@Get('leaderboard')
async getLeaderboard(@CurrentUser() user?: any, @Query('limit') limit = '10') {
const limitNum = Math.min(parseInt(limit, 10) || 10, 100);
const leaderboard = await this.userStatsRepository.getLeaderboard(limitNum);
if (user) {
this.logger.log(`Getting leaderboard with user ${user.sub} position`);
const userPosition = await this.userStatsRepository.getUserPosition(user.sub);
return {
leaderboard,
userPosition,
userId: user.sub,
limit: limitNum,
};
}
if (user) {
this.logger.log(`Getting leaderboard with user ${user.sub} position`);
const userPosition = await this.userStatsRepository.getUserPosition(user.sub);
return {
leaderboard,
userPosition,
userId: user.sub,
limit: limitNum,
};
}
return {
leaderboard,
limit: limitNum,
};
}
return {
leaderboard,
limit: limitNum,
};
}
@Public()
@Get('deck-templates')
async getDeckTemplates(@Query('category') category?: string) {
const templates = category
? await this.deckTemplateRepository.findByCategory(category)
: await this.deckTemplateRepository.findPublic();
@Public()
@Get('deck-templates')
async getDeckTemplates(@Query('category') category?: string) {
const templates = category
? await this.deckTemplateRepository.findByCategory(category)
: await this.deckTemplateRepository.findPublic();
return {
category: category || 'all',
templates,
count: templates.length,
};
}
return {
category: category || 'all',
templates,
count: templates.length,
};
}
@Public()
@Get('announcements')
getAnnouncements() {
return {
announcements: [
{
id: '1',
title: 'Welcome to ManaDeck!',
content: 'Your deck management system is now powered by Mana Core authentication.',
date: new Date(),
},
],
};
}
}
@Public()
@Get('announcements')
getAnnouncements() {
return {
announcements: [
{
id: '1',
title: 'Welcome to ManaDeck!',
content: 'Your deck management system is now powered by Mana Core authentication.',
date: new Date(),
},
],
};
}
}

View file

@ -5,25 +5,25 @@ export const DATABASE_TOKEN = 'DATABASE';
@Global()
@Module({
providers: [
{
provide: DATABASE_TOKEN,
useFactory: () => {
const logger = new Logger('DatabaseModule');
logger.log('Initializing database connection');
return getDb();
},
},
],
exports: [DATABASE_TOKEN],
providers: [
{
provide: DATABASE_TOKEN,
useFactory: () => {
const logger = new Logger('DatabaseModule');
logger.log('Initializing database connection');
return getDb();
},
},
],
exports: [DATABASE_TOKEN],
})
export class DatabaseModule implements OnModuleDestroy {
private readonly logger = new Logger(DatabaseModule.name);
private readonly logger = new Logger(DatabaseModule.name);
async onModuleDestroy() {
this.logger.log('Closing database connection');
await closeDb();
}
async onModuleDestroy() {
this.logger.log('Closing database connection');
await closeDb();
}
}
export type { Database };

View file

@ -1,127 +1,124 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
cardProgress,
cards,
type CardProgress,
type NewCardProgress,
eq,
and,
lte,
sql,
cardProgress,
cards,
type CardProgress,
type NewCardProgress,
eq,
and,
lte,
sql,
} from '@manacore/manadeck-database';
@Injectable()
export class CardProgressRepository {
private readonly logger = new Logger(CardProgressRepository.name);
private readonly logger = new Logger(CardProgressRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async findByUserId(userId: string): Promise<CardProgress[]> {
this.logger.debug(`Finding card progress for user: ${userId}`);
return this.db
.select()
.from(cardProgress)
.where(eq(cardProgress.userId, userId));
}
async findByUserId(userId: string): Promise<CardProgress[]> {
this.logger.debug(`Finding card progress for user: ${userId}`);
return this.db.select().from(cardProgress).where(eq(cardProgress.userId, userId));
}
async findByDeckId(deckId: string, userId: string): Promise<CardProgress[]> {
// Join with cards to filter by deck
const result = await this.db
.select({ progress: cardProgress })
.from(cardProgress)
.innerJoin(cards, eq(cardProgress.cardId, cards.id))
.where(and(eq(cards.deckId, deckId), eq(cardProgress.userId, userId)));
return result.map((r) => r.progress);
}
async findByDeckId(deckId: string, userId: string): Promise<CardProgress[]> {
// Join with cards to filter by deck
const result = await this.db
.select({ progress: cardProgress })
.from(cardProgress)
.innerJoin(cards, eq(cardProgress.cardId, cards.id))
.where(and(eq(cards.deckId, deckId), eq(cardProgress.userId, userId)));
return result.map((r) => r.progress);
}
async findByCardId(cardId: string, userId: string): Promise<CardProgress | null> {
const result = await this.db
.select()
.from(cardProgress)
.where(and(eq(cardProgress.cardId, cardId), eq(cardProgress.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByCardId(cardId: string, userId: string): Promise<CardProgress | null> {
const result = await this.db
.select()
.from(cardProgress)
.where(and(eq(cardProgress.cardId, cardId), eq(cardProgress.userId, userId)))
.limit(1);
return result[0] || null;
}
async findDueCards(userId: string, deckId?: string): Promise<CardProgress[]> {
const now = new Date();
async findDueCards(userId: string, deckId?: string): Promise<CardProgress[]> {
const now = new Date();
if (deckId) {
const result = await this.db
.select({ progress: cardProgress })
.from(cardProgress)
.innerJoin(cards, eq(cardProgress.cardId, cards.id))
.where(
and(
eq(cardProgress.userId, userId),
eq(cards.deckId, deckId),
lte(cardProgress.nextReview, now)
)
);
return result.map((r) => r.progress);
}
if (deckId) {
const result = await this.db
.select({ progress: cardProgress })
.from(cardProgress)
.innerJoin(cards, eq(cardProgress.cardId, cards.id))
.where(
and(
eq(cardProgress.userId, userId),
eq(cards.deckId, deckId),
lte(cardProgress.nextReview, now)
)
);
return result.map((r) => r.progress);
}
return this.db
.select()
.from(cardProgress)
.where(and(eq(cardProgress.userId, userId), lte(cardProgress.nextReview, now)));
}
return this.db
.select()
.from(cardProgress)
.where(and(eq(cardProgress.userId, userId), lte(cardProgress.nextReview, now)));
}
async create(data: NewCardProgress): Promise<CardProgress> {
this.logger.debug(`Creating card progress for card: ${data.cardId}`);
const result = await this.db.insert(cardProgress).values(data).returning();
return result[0];
}
async create(data: NewCardProgress): Promise<CardProgress> {
this.logger.debug(`Creating card progress for card: ${data.cardId}`);
const result = await this.db.insert(cardProgress).values(data).returning();
return result[0];
}
async upsert(data: NewCardProgress): Promise<CardProgress> {
this.logger.debug(`Upserting card progress for card: ${data.cardId}`);
const result = await this.db
.insert(cardProgress)
.values(data)
.onConflictDoUpdate({
target: [cardProgress.userId, cardProgress.cardId],
set: {
easeFactor: data.easeFactor,
interval: data.interval,
repetitions: data.repetitions,
lastReviewed: data.lastReviewed,
nextReview: data.nextReview,
status: data.status,
updatedAt: new Date(),
},
})
.returning();
return result[0];
}
async upsert(data: NewCardProgress): Promise<CardProgress> {
this.logger.debug(`Upserting card progress for card: ${data.cardId}`);
const result = await this.db
.insert(cardProgress)
.values(data)
.onConflictDoUpdate({
target: [cardProgress.userId, cardProgress.cardId],
set: {
easeFactor: data.easeFactor,
interval: data.interval,
repetitions: data.repetitions,
lastReviewed: data.lastReviewed,
nextReview: data.nextReview,
status: data.status,
updatedAt: new Date(),
},
})
.returning();
return result[0];
}
async update(
id: string,
data: Partial<Omit<NewCardProgress, 'id' | 'userId' | 'cardId' | 'createdAt'>>
): Promise<CardProgress | null> {
this.logger.debug(`Updating card progress: ${id}`);
const result = await this.db
.update(cardProgress)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(cardProgress.id, id))
.returning();
return result[0] || null;
}
async update(
id: string,
data: Partial<Omit<NewCardProgress, 'id' | 'userId' | 'cardId' | 'createdAt'>>
): Promise<CardProgress | null> {
this.logger.debug(`Updating card progress: ${id}`);
const result = await this.db
.update(cardProgress)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(cardProgress.id, id))
.returning();
return result[0] || null;
}
async getStatsByUserId(userId: string) {
const result = await this.db
.select({
totalCards: sql<number>`count(*)::int`,
newCards: sql<number>`count(*) filter (where ${cardProgress.status} = 'new')::int`,
learningCards: sql<number>`count(*) filter (where ${cardProgress.status} = 'learning')::int`,
reviewCards: sql<number>`count(*) filter (where ${cardProgress.status} = 'review')::int`,
avgEaseFactor: sql<string>`avg(${cardProgress.easeFactor})`,
})
.from(cardProgress)
.where(eq(cardProgress.userId, userId));
return result[0];
}
async getStatsByUserId(userId: string) {
const result = await this.db
.select({
totalCards: sql<number>`count(*)::int`,
newCards: sql<number>`count(*) filter (where ${cardProgress.status} = 'new')::int`,
learningCards: sql<number>`count(*) filter (where ${cardProgress.status} = 'learning')::int`,
reviewCards: sql<number>`count(*) filter (where ${cardProgress.status} = 'review')::int`,
avgEaseFactor: sql<string>`avg(${cardProgress.easeFactor})`,
})
.from(cardProgress)
.where(eq(cardProgress.userId, userId));
return result[0];
}
}

View file

@ -1,125 +1,118 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
cards,
decks,
type Card,
type NewCard,
eq,
and,
asc,
sql,
cards,
decks,
type Card,
type NewCard,
eq,
and,
asc,
sql,
} from '@manacore/manadeck-database';
@Injectable()
export class CardRepository {
private readonly logger = new Logger(CardRepository.name);
private readonly logger = new Logger(CardRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async findByDeckId(deckId: string): Promise<Card[]> {
this.logger.debug(`Finding cards for deck: ${deckId}`);
return this.db
.select()
.from(cards)
.where(eq(cards.deckId, deckId))
.orderBy(asc(cards.position));
}
async findByDeckId(deckId: string): Promise<Card[]> {
this.logger.debug(`Finding cards for deck: ${deckId}`);
return this.db
.select()
.from(cards)
.where(eq(cards.deckId, deckId))
.orderBy(asc(cards.position));
}
async findByDeckIdAndUserId(deckId: string, userId: string): Promise<Card[]> {
// Join with decks to verify ownership
const result = await this.db
.select({
card: cards,
})
.from(cards)
.innerJoin(decks, eq(cards.deckId, decks.id))
.where(and(eq(cards.deckId, deckId), eq(decks.userId, userId)))
.orderBy(asc(cards.position));
return result.map((r) => r.card);
}
async findByDeckIdAndUserId(deckId: string, userId: string): Promise<Card[]> {
// Join with decks to verify ownership
const result = await this.db
.select({
card: cards,
})
.from(cards)
.innerJoin(decks, eq(cards.deckId, decks.id))
.where(and(eq(cards.deckId, deckId), eq(decks.userId, userId)))
.orderBy(asc(cards.position));
return result.map((r) => r.card);
}
async findById(id: string): Promise<Card | null> {
const result = await this.db
.select()
.from(cards)
.where(eq(cards.id, id))
.limit(1);
return result[0] || null;
}
async findById(id: string): Promise<Card | null> {
const result = await this.db.select().from(cards).where(eq(cards.id, id)).limit(1);
return result[0] || null;
}
async findByUserDecks(userId: string): Promise<Card[]> {
// Get all cards from decks owned by the user
const result = await this.db
.select({
card: cards,
})
.from(cards)
.innerJoin(decks, eq(cards.deckId, decks.id))
.where(eq(decks.userId, userId))
.orderBy(asc(cards.deckId), asc(cards.position));
return result.map((r) => r.card);
}
async findByUserDecks(userId: string): Promise<Card[]> {
// Get all cards from decks owned by the user
const result = await this.db
.select({
card: cards,
})
.from(cards)
.innerJoin(decks, eq(cards.deckId, decks.id))
.where(eq(decks.userId, userId))
.orderBy(asc(cards.deckId), asc(cards.position));
return result.map((r) => r.card);
}
async create(data: NewCard): Promise<Card> {
this.logger.debug(`Creating card in deck: ${data.deckId}`);
const result = await this.db.insert(cards).values(data).returning();
return result[0];
}
async create(data: NewCard): Promise<Card> {
this.logger.debug(`Creating card in deck: ${data.deckId}`);
const result = await this.db.insert(cards).values(data).returning();
return result[0];
}
async createMany(data: NewCard[]): Promise<Card[]> {
if (data.length === 0) return [];
this.logger.debug(`Creating ${data.length} cards`);
return this.db.insert(cards).values(data).returning();
}
async createMany(data: NewCard[]): Promise<Card[]> {
if (data.length === 0) return [];
this.logger.debug(`Creating ${data.length} cards`);
return this.db.insert(cards).values(data).returning();
}
async update(
id: string,
data: Partial<Omit<NewCard, 'id' | 'deckId' | 'createdAt'>>
): Promise<Card | null> {
this.logger.debug(`Updating card: ${id}`);
const result = await this.db
.update(cards)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(cards.id, id))
.returning();
return result[0] || null;
}
async update(
id: string,
data: Partial<Omit<NewCard, 'id' | 'deckId' | 'createdAt'>>
): Promise<Card | null> {
this.logger.debug(`Updating card: ${id}`);
const result = await this.db
.update(cards)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(cards.id, id))
.returning();
return result[0] || null;
}
async delete(id: string): Promise<boolean> {
this.logger.debug(`Deleting card: ${id}`);
const result = await this.db
.delete(cards)
.where(eq(cards.id, id))
.returning({ id: cards.id });
return result.length > 0;
}
async delete(id: string): Promise<boolean> {
this.logger.debug(`Deleting card: ${id}`);
const result = await this.db.delete(cards).where(eq(cards.id, id)).returning({ id: cards.id });
return result.length > 0;
}
async deleteByDeckId(deckId: string): Promise<number> {
const result = await this.db
.delete(cards)
.where(eq(cards.deckId, deckId))
.returning({ id: cards.id });
return result.length;
}
async deleteByDeckId(deckId: string): Promise<number> {
const result = await this.db
.delete(cards)
.where(eq(cards.deckId, deckId))
.returning({ id: cards.id });
return result.length;
}
async countByDeckId(deckId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(cards)
.where(eq(cards.deckId, deckId));
return result[0]?.count || 0;
}
async countByDeckId(deckId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(cards)
.where(eq(cards.deckId, deckId));
return result[0]?.count || 0;
}
async countByUserId(userId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(cards)
.innerJoin(decks, eq(cards.deckId, decks.id))
.where(eq(decks.userId, userId));
return result[0]?.count || 0;
}
async countByUserId(userId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(cards)
.innerJoin(decks, eq(cards.deckId, decks.id))
.where(eq(decks.userId, userId));
return result[0]?.count || 0;
}
}

View file

@ -1,121 +1,106 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
deckTemplates,
type DeckTemplate,
type NewDeckTemplate,
eq,
and,
desc,
sql,
deckTemplates,
type DeckTemplate,
type NewDeckTemplate,
eq,
and,
desc,
sql,
} from '@manacore/manadeck-database';
@Injectable()
export class DeckTemplateRepository {
private readonly logger = new Logger(DeckTemplateRepository.name);
private readonly logger = new Logger(DeckTemplateRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async findAll(includeInactive = false): Promise<DeckTemplate[]> {
if (includeInactive) {
return this.db
.select()
.from(deckTemplates)
.orderBy(desc(deckTemplates.popularity));
}
async findAll(includeInactive = false): Promise<DeckTemplate[]> {
if (includeInactive) {
return this.db.select().from(deckTemplates).orderBy(desc(deckTemplates.popularity));
}
return this.db
.select()
.from(deckTemplates)
.where(eq(deckTemplates.isActive, true))
.orderBy(desc(deckTemplates.popularity));
}
return this.db
.select()
.from(deckTemplates)
.where(eq(deckTemplates.isActive, true))
.orderBy(desc(deckTemplates.popularity));
}
async findById(id: string): Promise<DeckTemplate | null> {
const result = await this.db
.select()
.from(deckTemplates)
.where(eq(deckTemplates.id, id))
.limit(1);
return result[0] || null;
}
async findById(id: string): Promise<DeckTemplate | null> {
const result = await this.db
.select()
.from(deckTemplates)
.where(eq(deckTemplates.id, id))
.limit(1);
return result[0] || null;
}
async findByCategory(category: string): Promise<DeckTemplate[]> {
return this.db
.select()
.from(deckTemplates)
.where(
and(
eq(deckTemplates.category, category),
eq(deckTemplates.isActive, true)
)
)
.orderBy(desc(deckTemplates.popularity));
}
async findByCategory(category: string): Promise<DeckTemplate[]> {
return this.db
.select()
.from(deckTemplates)
.where(and(eq(deckTemplates.category, category), eq(deckTemplates.isActive, true)))
.orderBy(desc(deckTemplates.popularity));
}
async findPublic(): Promise<DeckTemplate[]> {
return this.db
.select()
.from(deckTemplates)
.where(
and(eq(deckTemplates.isPublic, true), eq(deckTemplates.isActive, true))
)
.orderBy(desc(deckTemplates.popularity));
}
async findPublic(): Promise<DeckTemplate[]> {
return this.db
.select()
.from(deckTemplates)
.where(and(eq(deckTemplates.isPublic, true), eq(deckTemplates.isActive, true)))
.orderBy(desc(deckTemplates.popularity));
}
async create(data: NewDeckTemplate): Promise<DeckTemplate> {
this.logger.debug(`Creating deck template: ${data.title}`);
const result = await this.db
.insert(deckTemplates)
.values(data)
.returning();
return result[0];
}
async create(data: NewDeckTemplate): Promise<DeckTemplate> {
this.logger.debug(`Creating deck template: ${data.title}`);
const result = await this.db.insert(deckTemplates).values(data).returning();
return result[0];
}
async update(
id: string,
data: Partial<Omit<NewDeckTemplate, 'id' | 'createdAt'>>
): Promise<DeckTemplate | null> {
this.logger.debug(`Updating deck template: ${id}`);
const result = await this.db
.update(deckTemplates)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(deckTemplates.id, id))
.returning();
return result[0] || null;
}
async update(
id: string,
data: Partial<Omit<NewDeckTemplate, 'id' | 'createdAt'>>
): Promise<DeckTemplate | null> {
this.logger.debug(`Updating deck template: ${id}`);
const result = await this.db
.update(deckTemplates)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(deckTemplates.id, id))
.returning();
return result[0] || null;
}
async delete(id: string): Promise<boolean> {
this.logger.debug(`Deleting deck template: ${id}`);
const result = await this.db
.delete(deckTemplates)
.where(eq(deckTemplates.id, id))
.returning({ id: deckTemplates.id });
return result.length > 0;
}
async delete(id: string): Promise<boolean> {
this.logger.debug(`Deleting deck template: ${id}`);
const result = await this.db
.delete(deckTemplates)
.where(eq(deckTemplates.id, id))
.returning({ id: deckTemplates.id });
return result.length > 0;
}
async incrementPopularity(id: string): Promise<DeckTemplate | null> {
const result = await this.db
.update(deckTemplates)
.set({
popularity: sql`${deckTemplates.popularity} + 1`,
updatedAt: new Date(),
})
.where(eq(deckTemplates.id, id))
.returning();
return result[0] || null;
}
async incrementPopularity(id: string): Promise<DeckTemplate | null> {
const result = await this.db
.update(deckTemplates)
.set({
popularity: sql`${deckTemplates.popularity} + 1`,
updatedAt: new Date(),
})
.where(eq(deckTemplates.id, id))
.returning();
return result[0] || null;
}
async getCategories(): Promise<string[]> {
const result = await this.db
.selectDistinct({ category: deckTemplates.category })
.from(deckTemplates)
.where(eq(deckTemplates.isActive, true));
return result
.map((r) => r.category)
.filter((c): c is string => c !== null);
}
async getCategories(): Promise<string[]> {
const result = await this.db
.selectDistinct({ category: deckTemplates.category })
.from(deckTemplates)
.where(eq(deckTemplates.isActive, true));
return result.map((r) => r.category).filter((c): c is string => c !== null);
}
}

View file

@ -1,103 +1,91 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
decks,
type Deck,
type NewDeck,
eq,
and,
desc,
sql,
} from '@manacore/manadeck-database';
import { decks, type Deck, type NewDeck, eq, and, desc, sql } from '@manacore/manadeck-database';
@Injectable()
export class DeckRepository {
private readonly logger = new Logger(DeckRepository.name);
private readonly logger = new Logger(DeckRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async findByUserId(userId: string): Promise<Deck[]> {
this.logger.debug(`Finding decks for user: ${userId}`);
return this.db
.select()
.from(decks)
.where(eq(decks.userId, userId))
.orderBy(desc(decks.createdAt));
}
async findByUserId(userId: string): Promise<Deck[]> {
this.logger.debug(`Finding decks for user: ${userId}`);
return this.db
.select()
.from(decks)
.where(eq(decks.userId, userId))
.orderBy(desc(decks.createdAt));
}
async findById(id: string): Promise<Deck | null> {
const result = await this.db
.select()
.from(decks)
.where(eq(decks.id, id))
.limit(1);
return result[0] || null;
}
async findById(id: string): Promise<Deck | null> {
const result = await this.db.select().from(decks).where(eq(decks.id, id)).limit(1);
return result[0] || null;
}
async findByIdAndUserId(id: string, userId: string): Promise<Deck | null> {
const result = await this.db
.select()
.from(decks)
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
.limit(1);
return result[0] || null;
}
async findByIdAndUserId(id: string, userId: string): Promise<Deck | null> {
const result = await this.db
.select()
.from(decks)
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
.limit(1);
return result[0] || null;
}
async create(data: NewDeck): Promise<Deck> {
this.logger.debug(`Creating deck: ${data.title}`);
const result = await this.db.insert(decks).values(data).returning();
return result[0];
}
async create(data: NewDeck): Promise<Deck> {
this.logger.debug(`Creating deck: ${data.title}`);
const result = await this.db.insert(decks).values(data).returning();
return result[0];
}
async update(
id: string,
userId: string,
data: Partial<Omit<NewDeck, 'id' | 'userId' | 'createdAt'>>
): Promise<Deck | null> {
this.logger.debug(`Updating deck: ${id}`);
const result = await this.db
.update(decks)
.set({
...data,
updatedAt: new Date(),
})
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
.returning();
return result[0] || null;
}
async update(
id: string,
userId: string,
data: Partial<Omit<NewDeck, 'id' | 'userId' | 'createdAt'>>
): Promise<Deck | null> {
this.logger.debug(`Updating deck: ${id}`);
const result = await this.db
.update(decks)
.set({
...data,
updatedAt: new Date(),
})
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
.returning();
return result[0] || null;
}
async delete(id: string, userId: string): Promise<boolean> {
this.logger.debug(`Deleting deck: ${id}`);
const result = await this.db
.delete(decks)
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
.returning({ id: decks.id });
return result.length > 0;
}
async delete(id: string, userId: string): Promise<boolean> {
this.logger.debug(`Deleting deck: ${id}`);
const result = await this.db
.delete(decks)
.where(and(eq(decks.id, id), eq(decks.userId, userId)))
.returning({ id: decks.id });
return result.length > 0;
}
async findFeatured(limit = 10): Promise<Deck[]> {
return this.db
.select()
.from(decks)
.where(and(eq(decks.isFeatured, true), eq(decks.isPublic, true)))
.orderBy(desc(decks.featuredAt))
.limit(limit);
}
async findFeatured(limit = 10): Promise<Deck[]> {
return this.db
.select()
.from(decks)
.where(and(eq(decks.isFeatured, true), eq(decks.isPublic, true)))
.orderBy(desc(decks.featuredAt))
.limit(limit);
}
async findPublic(limit = 10): Promise<Deck[]> {
return this.db
.select()
.from(decks)
.where(eq(decks.isPublic, true))
.orderBy(desc(decks.createdAt))
.limit(limit);
}
async findPublic(limit = 10): Promise<Deck[]> {
return this.db
.select()
.from(decks)
.where(eq(decks.isPublic, true))
.orderBy(desc(decks.createdAt))
.limit(limit);
}
async countByUserId(userId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(decks)
.where(eq(decks.userId, userId));
return result[0]?.count || 0;
}
async countByUserId(userId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(decks)
.where(eq(decks.userId, userId));
return result[0]?.count || 0;
}
}

View file

@ -1,98 +1,94 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
studySessions,
type StudySession,
type NewStudySession,
eq,
and,
desc,
gte,
lte,
sql,
studySessions,
type StudySession,
type NewStudySession,
eq,
and,
desc,
gte,
lte,
sql,
} from '@manacore/manadeck-database';
@Injectable()
export class StudySessionRepository {
private readonly logger = new Logger(StudySessionRepository.name);
private readonly logger = new Logger(StudySessionRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async findByUserId(userId: string, limit = 50): Promise<StudySession[]> {
this.logger.debug(`Finding study sessions for user: ${userId}`);
return this.db
.select()
.from(studySessions)
.where(eq(studySessions.userId, userId))
.orderBy(desc(studySessions.startedAt))
.limit(limit);
}
async findByUserId(userId: string, limit = 50): Promise<StudySession[]> {
this.logger.debug(`Finding study sessions for user: ${userId}`);
return this.db
.select()
.from(studySessions)
.where(eq(studySessions.userId, userId))
.orderBy(desc(studySessions.startedAt))
.limit(limit);
}
async findByDeckId(deckId: string, userId: string): Promise<StudySession[]> {
return this.db
.select()
.from(studySessions)
.where(and(eq(studySessions.deckId, deckId), eq(studySessions.userId, userId)))
.orderBy(desc(studySessions.startedAt));
}
async findByDeckId(deckId: string, userId: string): Promise<StudySession[]> {
return this.db
.select()
.from(studySessions)
.where(and(eq(studySessions.deckId, deckId), eq(studySessions.userId, userId)))
.orderBy(desc(studySessions.startedAt));
}
async findByDateRange(
userId: string,
startDate: Date,
endDate: Date
): Promise<StudySession[]> {
return this.db
.select()
.from(studySessions)
.where(
and(
eq(studySessions.userId, userId),
gte(studySessions.startedAt, startDate),
lte(studySessions.startedAt, endDate)
)
)
.orderBy(desc(studySessions.startedAt));
}
async findByDateRange(userId: string, startDate: Date, endDate: Date): Promise<StudySession[]> {
return this.db
.select()
.from(studySessions)
.where(
and(
eq(studySessions.userId, userId),
gte(studySessions.startedAt, startDate),
lte(studySessions.startedAt, endDate)
)
)
.orderBy(desc(studySessions.startedAt));
}
async findById(id: string): Promise<StudySession | null> {
const result = await this.db
.select()
.from(studySessions)
.where(eq(studySessions.id, id))
.limit(1);
return result[0] || null;
}
async findById(id: string): Promise<StudySession | null> {
const result = await this.db
.select()
.from(studySessions)
.where(eq(studySessions.id, id))
.limit(1);
return result[0] || null;
}
async create(data: NewStudySession): Promise<StudySession> {
this.logger.debug(`Creating study session for deck: ${data.deckId}`);
const result = await this.db.insert(studySessions).values(data).returning();
return result[0];
}
async create(data: NewStudySession): Promise<StudySession> {
this.logger.debug(`Creating study session for deck: ${data.deckId}`);
const result = await this.db.insert(studySessions).values(data).returning();
return result[0];
}
async update(
id: string,
userId: string,
data: Partial<Omit<NewStudySession, 'id' | 'userId' | 'deckId' | 'startedAt'>>
): Promise<StudySession | null> {
this.logger.debug(`Updating study session: ${id}`);
const result = await this.db
.update(studySessions)
.set(data)
.where(and(eq(studySessions.id, id), eq(studySessions.userId, userId)))
.returning();
return result[0] || null;
}
async update(
id: string,
userId: string,
data: Partial<Omit<NewStudySession, 'id' | 'userId' | 'deckId' | 'startedAt'>>
): Promise<StudySession | null> {
this.logger.debug(`Updating study session: ${id}`);
const result = await this.db
.update(studySessions)
.set(data)
.where(and(eq(studySessions.id, id), eq(studySessions.userId, userId)))
.returning();
return result[0] || null;
}
async getStatsByUserId(userId: string) {
const result = await this.db
.select({
totalSessions: sql<number>`count(*)::int`,
totalCardsStudied: sql<number>`sum(${studySessions.completedCards})::int`,
totalCorrectCards: sql<number>`sum(${studySessions.correctCards})::int`,
totalTimeSeconds: sql<number>`sum(${studySessions.timeSpentSeconds})::int`,
})
.from(studySessions)
.where(eq(studySessions.userId, userId));
return result[0];
}
async getStatsByUserId(userId: string) {
const result = await this.db
.select({
totalSessions: sql<number>`count(*)::int`,
totalCardsStudied: sql<number>`sum(${studySessions.completedCards})::int`,
totalCorrectCards: sql<number>`sum(${studySessions.correctCards})::int`,
totalTimeSeconds: sql<number>`sum(${studySessions.timeSpentSeconds})::int`,
})
.from(studySessions)
.where(eq(studySessions.userId, userId));
return result[0];
}
}

View file

@ -1,146 +1,133 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { DATABASE_TOKEN, type Database } from '../database.module';
import {
userStats,
type UserStats,
type NewUserStats,
eq,
desc,
sql,
userStats,
type UserStats,
type NewUserStats,
eq,
desc,
sql,
} from '@manacore/manadeck-database';
@Injectable()
export class UserStatsRepository {
private readonly logger = new Logger(UserStatsRepository.name);
private readonly logger = new Logger(UserStatsRepository.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
async findByUserId(userId: string): Promise<UserStats | null> {
this.logger.debug(`Finding stats for user: ${userId}`);
const result = await this.db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1);
return result[0] || null;
}
async findByUserId(userId: string): Promise<UserStats | null> {
this.logger.debug(`Finding stats for user: ${userId}`);
const result = await this.db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1);
return result[0] || null;
}
async findOrCreate(userId: string): Promise<UserStats> {
const existing = await this.findByUserId(userId);
if (existing) return existing;
async findOrCreate(userId: string): Promise<UserStats> {
const existing = await this.findByUserId(userId);
if (existing) return existing;
this.logger.debug(`Creating stats for user: ${userId}`);
const result = await this.db
.insert(userStats)
.values({ userId })
.returning();
return result[0];
}
this.logger.debug(`Creating stats for user: ${userId}`);
const result = await this.db.insert(userStats).values({ userId }).returning();
return result[0];
}
async update(
userId: string,
data: Partial<Omit<NewUserStats, 'userId' | 'createdAt'>>
): Promise<UserStats | null> {
this.logger.debug(`Updating stats for user: ${userId}`);
const result = await this.db
.update(userStats)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
.returning();
return result[0] || null;
}
async update(
userId: string,
data: Partial<Omit<NewUserStats, 'userId' | 'createdAt'>>
): Promise<UserStats | null> {
this.logger.debug(`Updating stats for user: ${userId}`);
const result = await this.db
.update(userStats)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
.returning();
return result[0] || null;
}
async getLeaderboard(limit = 10): Promise<UserStats[]> {
return this.db
.select()
.from(userStats)
.orderBy(desc(userStats.totalWins))
.limit(limit);
}
async getLeaderboard(limit = 10): Promise<UserStats[]> {
return this.db.select().from(userStats).orderBy(desc(userStats.totalWins)).limit(limit);
}
async getLeaderboardByStreak(limit = 10): Promise<UserStats[]> {
return this.db
.select()
.from(userStats)
.orderBy(desc(userStats.streakDays))
.limit(limit);
}
async getLeaderboardByStreak(limit = 10): Promise<UserStats[]> {
return this.db.select().from(userStats).orderBy(desc(userStats.streakDays)).limit(limit);
}
async getUserPosition(userId: string): Promise<number | null> {
// Get user's total wins
const user = await this.findByUserId(userId);
if (!user) return null;
async getUserPosition(userId: string): Promise<number | null> {
// Get user's total wins
const user = await this.findByUserId(userId);
if (!user) return null;
// Count users with higher wins
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(userStats)
.where(sql`${userStats.totalWins} > ${user.totalWins}`);
// Count users with higher wins
const result = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(userStats)
.where(sql`${userStats.totalWins} > ${user.totalWins}`);
return (result[0]?.count || 0) + 1;
}
return (result[0]?.count || 0) + 1;
}
async incrementWins(userId: string, count = 1): Promise<UserStats | null> {
const result = await this.db
.update(userStats)
.set({
totalWins: sql`${userStats.totalWins} + ${count}`,
updatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
.returning();
return result[0] || null;
}
async incrementWins(userId: string, count = 1): Promise<UserStats | null> {
const result = await this.db
.update(userStats)
.set({
totalWins: sql`${userStats.totalWins} + ${count}`,
updatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
.returning();
return result[0] || null;
}
async incrementSessions(userId: string): Promise<UserStats | null> {
const result = await this.db
.update(userStats)
.set({
totalSessions: sql`${userStats.totalSessions} + 1`,
updatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
.returning();
return result[0] || null;
}
async incrementSessions(userId: string): Promise<UserStats | null> {
const result = await this.db
.update(userStats)
.set({
totalSessions: sql`${userStats.totalSessions} + 1`,
updatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
.returning();
return result[0] || null;
}
async updateStudyProgress(
userId: string,
cardsStudied: number,
timeSeconds: number,
accuracy: number
): Promise<UserStats | null> {
const today = new Date().toISOString().split('T')[0];
const existing = await this.findByUserId(userId);
async updateStudyProgress(
userId: string,
cardsStudied: number,
timeSeconds: number,
accuracy: number
): Promise<UserStats | null> {
const today = new Date().toISOString().split('T')[0];
const existing = await this.findByUserId(userId);
if (!existing) {
await this.findOrCreate(userId);
}
if (!existing) {
await this.findOrCreate(userId);
}
// Calculate new average accuracy
const currentAvg = existing ? parseFloat(existing.averageAccuracy) : 0;
const currentTotal = existing?.totalCardsStudied || 0;
const newTotal = currentTotal + cardsStudied;
const newAvg =
newTotal > 0
? (currentAvg * currentTotal + accuracy * cardsStudied) / newTotal
: accuracy;
// Calculate new average accuracy
const currentAvg = existing ? parseFloat(existing.averageAccuracy) : 0;
const currentTotal = existing?.totalCardsStudied || 0;
const newTotal = currentTotal + cardsStudied;
const newAvg =
newTotal > 0 ? (currentAvg * currentTotal + accuracy * cardsStudied) / newTotal : accuracy;
const result = await this.db
.update(userStats)
.set({
totalCardsStudied: sql`${userStats.totalCardsStudied} + ${cardsStudied}`,
totalTimeSeconds: sql`${userStats.totalTimeSeconds} + ${timeSeconds}`,
averageAccuracy: newAvg.toFixed(2),
lastStudyDate: today,
updatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
.returning();
const result = await this.db
.update(userStats)
.set({
totalCardsStudied: sql`${userStats.totalCardsStudied} + ${cardsStudied}`,
totalTimeSeconds: sql`${userStats.totalTimeSeconds} + ${timeSeconds}`,
averageAccuracy: newAvg.toFixed(2),
lastStudyDate: today,
updatedAt: new Date(),
})
.where(eq(userStats.userId, userId))
.returning();
return result[0] || null;
}
return result[0] || null;
}
}

View file

@ -5,47 +5,49 @@ import { AppExceptionFilter } from '@manacore/shared-errors/nestjs';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const logger = new Logger('Bootstrap');
logger.log('Starting ManaDeck Backend...');
logger.log('Starting ManaDeck Backend...');
// Debug: Log environment variables before validation
logger.log('=== Environment Variables Debug ===');
logger.log(`APP_ID: ${process.env.APP_ID ? process.env.APP_ID.substring(0, 20) + '...' : 'NOT SET'}`);
logger.log(`DATABASE_URL: ${process.env.DATABASE_URL ? '[SET]' : 'NOT SET'}`);
logger.log(`MANA_SERVICE_URL: ${process.env.MANA_SERVICE_URL || 'NOT SET'}`);
logger.log('===================================');
// Debug: Log environment variables before validation
logger.log('=== Environment Variables Debug ===');
logger.log(
`APP_ID: ${process.env.APP_ID ? process.env.APP_ID.substring(0, 20) + '...' : 'NOT SET'}`
);
logger.log(`DATABASE_URL: ${process.env.DATABASE_URL ? '[SET]' : 'NOT SET'}`);
logger.log(`MANA_SERVICE_URL: ${process.env.MANA_SERVICE_URL || 'NOT SET'}`);
logger.log('===================================');
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
});
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
});
const configService = app.get(ConfigService);
const configService = app.get(ConfigService);
// Global exception filter for standardized error responses
app.useGlobalFilters(new AppExceptionFilter());
// Global exception filter for standardized error responses
app.useGlobalFilters(new AppExceptionFilter());
// Enable CORS
app.enableCors({
origin: configService.get('FRONTEND_URL') || true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
// Enable CORS
app.enableCors({
origin: configService.get('FRONTEND_URL') || true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
// Global prefix
app.setGlobalPrefix('v1', {
exclude: ['health', 'health/ready', 'health/live'],
});
// Global prefix
app.setGlobalPrefix('v1', {
exclude: ['health', 'health/ready', 'health/live'],
});
const port = configService.get<number>('PORT') || 8080;
const port = configService.get<number>('PORT') || 8080;
await app.listen(port);
await app.listen(port);
logger.log(`Environment: ${configService.get('NODE_ENV')}`);
logger.log(`Mana Service URL: ${configService.get('MANA_SERVICE_URL')}`);
logger.log(`App ID: ${configService.get('APP_ID')}`);
logger.log(`Application running on port ${port}`);
logger.log(`Health check available at http://localhost:${port}/health`);
logger.log(`Environment: ${configService.get('NODE_ENV')}`);
logger.log(`Mana Service URL: ${configService.get('MANA_SERVICE_URL')}`);
logger.log(`App ID: ${configService.get('APP_ID')}`);
logger.log(`Application running on port ${port}`);
logger.log(`Health check available at http://localhost:${port}/health`);
}
bootstrap();

View file

@ -1,170 +1,160 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenAI, Type } from '@google/genai';
import {
type AsyncResult,
ok,
err,
ServiceError,
} from '@manacore/shared-errors';
import { type AsyncResult, ok, err, ServiceError } from '@manacore/shared-errors';
export type CardType = 'text' | 'flashcard' | 'quiz' | 'mixed';
export interface TextContent {
text: string;
text: string;
}
export interface FlashcardContent {
front: string;
back: string;
hint?: string;
front: string;
back: string;
hint?: string;
}
export interface QuizContent {
question: string;
options: string[];
correctAnswer: number;
explanation?: string;
question: string;
options: string[];
correctAnswer: number;
explanation?: string;
}
export interface GeneratedCard {
cardType: CardType;
title?: string;
content: TextContent | FlashcardContent | QuizContent;
cardType: CardType;
title?: string;
content: TextContent | FlashcardContent | QuizContent;
}
export interface DeckGenerationRequest {
prompt: string;
deckTitle: string;
deckDescription?: string;
cardCount?: number;
cardTypes?: CardType[];
difficulty?: 'beginner' | 'intermediate' | 'advanced';
language?: string;
prompt: string;
deckTitle: string;
deckDescription?: string;
cardCount?: number;
cardTypes?: CardType[];
difficulty?: 'beginner' | 'intermediate' | 'advanced';
language?: string;
}
export interface DeckGenerationData {
cards: GeneratedCard[];
metadata: {
model: string;
tokensUsed?: number;
generationTime: number;
};
cards: GeneratedCard[];
metadata: {
model: string;
tokensUsed?: number;
generationTime: number;
};
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly ai: GoogleGenAI | null;
private readonly model = 'gemini-2.0-flash';
private readonly logger = new Logger(AiService.name);
private readonly ai: GoogleGenAI | null;
private readonly model = 'gemini-2.0-flash';
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>('GOOGLE_GENAI_API_KEY');
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>('GOOGLE_GENAI_API_KEY');
if (apiKey) {
this.ai = new GoogleGenAI({ apiKey });
this.logger.log('Google Gemini AI initialized successfully');
} else {
this.ai = null;
this.logger.warn('Google Gemini API key not configured - AI features disabled');
}
}
if (apiKey) {
this.ai = new GoogleGenAI({ apiKey });
this.logger.log('Google Gemini AI initialized successfully');
} else {
this.ai = null;
this.logger.warn('Google Gemini API key not configured - AI features disabled');
}
}
isAvailable(): boolean {
return this.ai !== null;
}
isAvailable(): boolean {
return this.ai !== null;
}
async generateDeck(request: DeckGenerationRequest): AsyncResult<DeckGenerationData> {
const startTime = Date.now();
async generateDeck(request: DeckGenerationRequest): AsyncResult<DeckGenerationData> {
const startTime = Date.now();
if (!this.ai) {
return err(
ServiceError.unavailable('AI (Google Gemini not configured)'),
);
}
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
const {
prompt,
deckTitle,
deckDescription,
cardCount = 10,
cardTypes = ['flashcard', 'quiz'],
difficulty = 'intermediate',
language = 'en',
} = request;
const {
prompt,
deckTitle,
deckDescription,
cardCount = 10,
cardTypes = ['flashcard', 'quiz'],
difficulty = 'intermediate',
language = 'en',
} = request;
try {
const systemPrompt = this.buildSystemPrompt(cardTypes, difficulty, language);
const userPrompt = this.buildUserPrompt(
prompt,
deckTitle,
deckDescription,
cardCount,
cardTypes,
);
try {
const systemPrompt = this.buildSystemPrompt(cardTypes, difficulty, language);
const userPrompt = this.buildUserPrompt(
prompt,
deckTitle,
deckDescription,
cardCount,
cardTypes
);
const response = await this.ai.models.generateContent({
model: this.model,
contents: userPrompt,
config: {
systemInstruction: systemPrompt,
responseMimeType: 'application/json',
responseSchema: this.buildResponseSchema(cardTypes),
},
});
const response = await this.ai.models.generateContent({
model: this.model,
contents: userPrompt,
config: {
systemInstruction: systemPrompt,
responseMimeType: 'application/json',
responseSchema: this.buildResponseSchema(cardTypes),
},
});
const generationTime = Date.now() - startTime;
const responseText = response.text?.trim();
const generationTime = Date.now() - startTime;
const responseText = response.text?.trim();
if (!responseText) {
return err(
ServiceError.generationFailed('Google Gemini', 'Empty response from AI'),
);
}
if (!responseText) {
return err(ServiceError.generationFailed('Google Gemini', 'Empty response from AI'));
}
const parsed = JSON.parse(responseText);
const cards: GeneratedCard[] = parsed.cards || [];
const parsed = JSON.parse(responseText);
const cards: GeneratedCard[] = parsed.cards || [];
if (cards.length === 0) {
return err(
ServiceError.generationFailed('Google Gemini', 'No cards generated'),
);
}
if (cards.length === 0) {
return err(ServiceError.generationFailed('Google Gemini', 'No cards generated'));
}
this.logger.log(`Generated ${cards.length} cards in ${generationTime}ms`);
this.logger.log(`Generated ${cards.length} cards in ${generationTime}ms`);
return ok({
cards,
metadata: {
model: this.model,
tokensUsed: response.usageMetadata?.totalTokenCount,
generationTime,
},
});
} catch (error) {
this.logger.error('AI deck generation failed:', error);
return ok({
cards,
metadata: {
model: this.model,
tokensUsed: response.usageMetadata?.totalTokenCount,
generationTime,
},
});
} catch (error) {
this.logger.error('AI deck generation failed:', error);
return err(
ServiceError.generationFailed(
'Google Gemini',
error instanceof Error ? error.message : 'Unknown error occurred',
error instanceof Error ? error : undefined,
),
);
}
}
return err(
ServiceError.generationFailed(
'Google Gemini',
error instanceof Error ? error.message : 'Unknown error occurred',
error instanceof Error ? error : undefined
)
);
}
}
private buildSystemPrompt(cardTypes: CardType[], difficulty: string, language: string): string {
const cardTypeDescriptions = {
text: 'Text cards contain informational content or explanations.',
flashcard: 'Flashcards have a front (question/term) and back (answer/definition), optionally with a hint.',
quiz: 'Quiz cards have a question, 4 options (A-D), correct answer index (0-3), and an explanation.',
mixed: 'Mixed cards combine multiple content types.',
};
private buildSystemPrompt(cardTypes: CardType[], difficulty: string, language: string): string {
const cardTypeDescriptions = {
text: 'Text cards contain informational content or explanations.',
flashcard:
'Flashcards have a front (question/term) and back (answer/definition), optionally with a hint.',
quiz: 'Quiz cards have a question, 4 options (A-D), correct answer index (0-3), and an explanation.',
mixed: 'Mixed cards combine multiple content types.',
};
const enabledTypes = cardTypes.map(t => `- ${t}: ${cardTypeDescriptions[t]}`).join('\n');
const enabledTypes = cardTypes.map((t) => `- ${t}: ${cardTypeDescriptions[t]}`).join('\n');
return `You are an expert educational content creator specializing in flashcards and study materials.
return `You are an expert educational content creator specializing in flashcards and study materials.
Your task is to generate high-quality learning cards for a deck based on the user's topic.
@ -187,18 +177,18 @@ QUALITY GUIDELINES:
5. Include helpful hints for difficult flashcards
6. Add explanations for quiz questions to reinforce learning
7. Progress from easier to harder cards when possible`;
}
}
private buildUserPrompt(
prompt: string,
deckTitle: string,
deckDescription?: string,
cardCount: number = 10,
cardTypes: CardType[] = ['flashcard', 'quiz'],
): string {
const typeDistribution = this.suggestTypeDistribution(cardCount, cardTypes);
private buildUserPrompt(
prompt: string,
deckTitle: string,
deckDescription?: string,
cardCount: number = 10,
cardTypes: CardType[] = ['flashcard', 'quiz']
): string {
const typeDistribution = this.suggestTypeDistribution(cardCount, cardTypes);
return `Create a deck of ${cardCount} learning cards about:
return `Create a deck of ${cardCount} learning cards about:
DECK TITLE: ${deckTitle}
${deckDescription ? `DESCRIPTION: ${deckDescription}` : ''}
@ -211,49 +201,49 @@ ${typeDistribution}
Generate exactly ${cardCount} cards that cover the topic comprehensively.
Ensure variety in the questions and good coverage of the subject matter.`;
}
}
private suggestTypeDistribution(cardCount: number, cardTypes: CardType[]): string {
if (cardTypes.length === 1) {
return `- All ${cardCount} cards should be ${cardTypes[0]} type`;
}
private suggestTypeDistribution(cardCount: number, cardTypes: CardType[]): string {
if (cardTypes.length === 1) {
return `- All ${cardCount} cards should be ${cardTypes[0]} type`;
}
const hasFlashcard = cardTypes.includes('flashcard');
const hasQuiz = cardTypes.includes('quiz');
const hasText = cardTypes.includes('text');
const hasFlashcard = cardTypes.includes('flashcard');
const hasQuiz = cardTypes.includes('quiz');
const hasText = cardTypes.includes('text');
if (hasFlashcard && hasQuiz && !hasText) {
const flashcardCount = Math.ceil(cardCount * 0.6);
const quizCount = cardCount - flashcardCount;
return `- ${flashcardCount} flashcards for core concepts\n- ${quizCount} quiz cards to test understanding`;
}
if (hasFlashcard && hasQuiz && !hasText) {
const flashcardCount = Math.ceil(cardCount * 0.6);
const quizCount = cardCount - flashcardCount;
return `- ${flashcardCount} flashcards for core concepts\n- ${quizCount} quiz cards to test understanding`;
}
if (hasFlashcard && hasQuiz && hasText) {
const textCount = Math.ceil(cardCount * 0.2);
const flashcardCount = Math.ceil((cardCount - textCount) * 0.6);
const quizCount = cardCount - textCount - flashcardCount;
return `- ${textCount} text cards for introductions/explanations\n- ${flashcardCount} flashcards for key terms\n- ${quizCount} quiz cards for testing`;
}
if (hasFlashcard && hasQuiz && hasText) {
const textCount = Math.ceil(cardCount * 0.2);
const flashcardCount = Math.ceil((cardCount - textCount) * 0.6);
const quizCount = cardCount - textCount - flashcardCount;
return `- ${textCount} text cards for introductions/explanations\n- ${flashcardCount} flashcards for key terms\n- ${quizCount} quiz cards for testing`;
}
return `- Mix of ${cardTypes.join(', ')} cards as appropriate for the content`;
}
return `- Mix of ${cardTypes.join(', ')} cards as appropriate for the content`;
}
/**
* Generate cards from an image using Gemini Vision
*/
async generateFromImage(
imageBase64: string,
context?: string,
cardCount: number = 5,
): AsyncResult<DeckGenerationData> {
const startTime = Date.now();
/**
* Generate cards from an image using Gemini Vision
*/
async generateFromImage(
imageBase64: string,
context?: string,
cardCount: number = 5
): AsyncResult<DeckGenerationData> {
const startTime = Date.now();
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
try {
const prompt = `Analyze this image and create ${cardCount} educational flashcards based on its content.
try {
const prompt = `Analyze this image and create ${cardCount} educational flashcards based on its content.
${context ? `Context: ${context}` : ''}
For each concept, term, or important element you identify in the image, create a flashcard or quiz question.
@ -263,168 +253,168 @@ Return the cards as a JSON object with a "cards" array containing objects with:
- title: short title
- content: { front, back, hint } for flashcards OR { question, options, correctAnswer, explanation } for quiz`;
const response = await this.ai.models.generateContent({
model: this.model,
contents: [
{
role: 'user',
parts: [
{ text: prompt },
{
inlineData: {
mimeType: 'image/jpeg',
data: imageBase64,
},
},
],
},
],
config: {
responseMimeType: 'application/json',
},
});
const response = await this.ai.models.generateContent({
model: this.model,
contents: [
{
role: 'user',
parts: [
{ text: prompt },
{
inlineData: {
mimeType: 'image/jpeg',
data: imageBase64,
},
},
],
},
],
config: {
responseMimeType: 'application/json',
},
});
const generationTime = Date.now() - startTime;
const responseText = response.text?.trim();
const generationTime = Date.now() - startTime;
const responseText = response.text?.trim();
if (!responseText) {
return err(ServiceError.generationFailed('Google Gemini', 'Empty response from AI'));
}
if (!responseText) {
return err(ServiceError.generationFailed('Google Gemini', 'Empty response from AI'));
}
const parsed = JSON.parse(responseText);
const cards: GeneratedCard[] = parsed.cards || [];
const parsed = JSON.parse(responseText);
const cards: GeneratedCard[] = parsed.cards || [];
this.logger.log(`Generated ${cards.length} cards from image in ${generationTime}ms`);
this.logger.log(`Generated ${cards.length} cards from image in ${generationTime}ms`);
return ok({
cards,
metadata: {
model: this.model,
tokensUsed: response.usageMetadata?.totalTokenCount,
generationTime,
},
});
} catch (error) {
this.logger.error('AI image generation failed:', error);
return err(
ServiceError.generationFailed(
'Google Gemini',
error instanceof Error ? error.message : 'Unknown error',
),
);
}
}
return ok({
cards,
metadata: {
model: this.model,
tokensUsed: response.usageMetadata?.totalTokenCount,
generationTime,
},
});
} catch (error) {
this.logger.error('AI image generation failed:', error);
return err(
ServiceError.generationFailed(
'Google Gemini',
error instanceof Error ? error.message : 'Unknown error'
)
);
}
}
/**
* Enhance card content using AI
*/
async enhanceContent(
content: string,
cardType: string,
): AsyncResult<{ enhancedContent: string }> {
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
/**
* Enhance card content using AI
*/
async enhanceContent(
content: string,
cardType: string
): AsyncResult<{ enhancedContent: string }> {
if (!this.ai) {
return err(ServiceError.unavailable('AI (Google Gemini not configured)'));
}
try {
const prompt = `Improve and enhance this ${cardType} card content. Make it clearer, more educational, and engaging.
try {
const prompt = `Improve and enhance this ${cardType} card content. Make it clearer, more educational, and engaging.
Original content:
${content}
Return the enhanced content in the same JSON format as the input, but improved.`;
const response = await this.ai.models.generateContent({
model: this.model,
contents: prompt,
config: {
responseMimeType: 'application/json',
},
});
const response = await this.ai.models.generateContent({
model: this.model,
contents: prompt,
config: {
responseMimeType: 'application/json',
},
});
const responseText = response.text?.trim();
if (!responseText) {
return ok({ enhancedContent: content });
}
const responseText = response.text?.trim();
if (!responseText) {
return ok({ enhancedContent: content });
}
return ok({ enhancedContent: responseText });
} catch (error) {
this.logger.error('AI content enhancement failed:', error);
return ok({ enhancedContent: content }); // Return original on failure
}
}
return ok({ enhancedContent: responseText });
} catch (error) {
this.logger.error('AI content enhancement failed:', error);
return ok({ enhancedContent: content }); // Return original on failure
}
}
private buildResponseSchema(cardTypes: CardType[]): any {
const cardSchemas: any[] = [];
private buildResponseSchema(cardTypes: CardType[]): any {
const cardSchemas: any[] = [];
if (cardTypes.includes('flashcard')) {
cardSchemas.push({
type: Type.OBJECT,
properties: {
cardType: { type: Type.STRING, enum: ['flashcard'] },
title: { type: Type.STRING },
content: {
type: Type.OBJECT,
properties: {
front: { type: Type.STRING },
back: { type: Type.STRING },
hint: { type: Type.STRING },
},
required: ['front', 'back'],
},
},
required: ['cardType', 'content'],
});
}
if (cardTypes.includes('flashcard')) {
cardSchemas.push({
type: Type.OBJECT,
properties: {
cardType: { type: Type.STRING, enum: ['flashcard'] },
title: { type: Type.STRING },
content: {
type: Type.OBJECT,
properties: {
front: { type: Type.STRING },
back: { type: Type.STRING },
hint: { type: Type.STRING },
},
required: ['front', 'back'],
},
},
required: ['cardType', 'content'],
});
}
if (cardTypes.includes('quiz')) {
cardSchemas.push({
type: Type.OBJECT,
properties: {
cardType: { type: Type.STRING, enum: ['quiz'] },
title: { type: Type.STRING },
content: {
type: Type.OBJECT,
properties: {
question: { type: Type.STRING },
options: { type: Type.ARRAY, items: { type: Type.STRING } },
correctAnswer: { type: Type.NUMBER },
explanation: { type: Type.STRING },
},
required: ['question', 'options', 'correctAnswer'],
},
},
required: ['cardType', 'content'],
});
}
if (cardTypes.includes('quiz')) {
cardSchemas.push({
type: Type.OBJECT,
properties: {
cardType: { type: Type.STRING, enum: ['quiz'] },
title: { type: Type.STRING },
content: {
type: Type.OBJECT,
properties: {
question: { type: Type.STRING },
options: { type: Type.ARRAY, items: { type: Type.STRING } },
correctAnswer: { type: Type.NUMBER },
explanation: { type: Type.STRING },
},
required: ['question', 'options', 'correctAnswer'],
},
},
required: ['cardType', 'content'],
});
}
if (cardTypes.includes('text')) {
cardSchemas.push({
type: Type.OBJECT,
properties: {
cardType: { type: Type.STRING, enum: ['text'] },
title: { type: Type.STRING },
content: {
type: Type.OBJECT,
properties: {
text: { type: Type.STRING },
},
required: ['text'],
},
},
required: ['cardType', 'content'],
});
}
if (cardTypes.includes('text')) {
cardSchemas.push({
type: Type.OBJECT,
properties: {
cardType: { type: Type.STRING, enum: ['text'] },
title: { type: Type.STRING },
content: {
type: Type.OBJECT,
properties: {
text: { type: Type.STRING },
},
required: ['text'],
},
},
required: ['cardType', 'content'],
});
}
return {
type: Type.OBJECT,
properties: {
cards: {
type: Type.ARRAY,
items: cardSchemas.length === 1 ? cardSchemas[0] : { anyOf: cardSchemas },
},
},
required: ['cards'],
};
}
return {
type: Type.OBJECT,
properties: {
cards: {
type: Type.ARRAY,
items: cardSchemas.length === 1 ? cardSchemas[0] : { anyOf: cardSchemas },
},
},
required: ['cards'],
};
}
}

View file

@ -5,21 +5,18 @@ import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
it('/ (GET)', () => {
return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
});
});

View file

@ -1,9 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View file

@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View file

@ -1,25 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

View file

@ -1,27 +1,27 @@
{
"name": "@manadeck/landing",
"version": "1.0.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",
"@manacore/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"
}
"name": "@manadeck/landing",
"version": "1.0.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",
"@manacore/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,78 +1,82 @@
---
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: '/imprint', label: 'Impressum' }
]
product: [
{ href: '#features', label: 'Features' },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' },
],
legal: [
{ href: '/privacy', label: 'Datenschutz' },
{ href: '/terms', label: 'AGB' },
{ 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">ManaDeck</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>
<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">ManaDeck</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>
<!-- 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>
<!-- 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} ManaDeck. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">
Made with 💜 in Germany
</p>
</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} ManaDeck. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">Made with 💜 in Germany</p>
</div>
</div>
</footer>

View file

@ -1,84 +1,89 @@
---
const navLinks = [
{ href: '#features', label: 'Features' },
{ href: '#how-it-works', label: 'So funktioniert\'s' },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' }
{ 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">ManaDeck</span>
</a>
<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">ManaDeck</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>
<!-- 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>
<!-- 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 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>
<!-- 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');
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
mobileMenuButton?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
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');
});
});
// Close menu when clicking a link
mobileMenu?.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => {
mobileMenu?.classList.add('hidden');
});
});
</script>

View file

@ -2,46 +2,49 @@
import '../styles/global.css';
interface Props {
title: string;
description?: string;
title: string;
description?: string;
}
const {
title,
description = 'ManaDeck - Dein KI-gestützter Lernpartner für Karteikarten und effektives Lernen'
title,
description = 'ManaDeck - 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} />
<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" />
<!-- 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" />
<!-- 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} />
<!-- 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" />
<!-- 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"
/>
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">
<slot />
</body>
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">
<slot />
</body>
</html>

View file

@ -14,274 +14,299 @@ import Card from '@manacore/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.'
}
{
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. ManaDeck 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'
}
{
number: '1',
title: 'Inhalte hochladen',
description:
'Lade deine Notizen, PDFs oder Texte hoch. ManaDeck 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'
}
}
{
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: 'ManaDeck 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: 'ManaDeck 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.'
}
{
question: 'Wie funktioniert die KI-Karteikarten-Generierung?',
answer:
'ManaDeck 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:
'ManaDeck 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="ManaDeck - KI-gestützte Karteikarten für effektives Lernen">
<Navigation />
<Navigation />
<main class="pt-16">
<HeroSection
title="Lerne smarter, nicht härter"
subtitle="ManaDeck 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' }
]}
/>
<main class="pt-16">
<HeroSection
title="Lerne smarter, nicht härter"
subtitle="ManaDeck 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="ManaDeck 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>
<FeatureSection
id="features"
title="Alles was du zum Lernen brauchst"
subtitle="ManaDeck 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}
/>
<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)]"
/>
<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 ManaDeck wissen musst"
faqs={faqs}
/>
<FAQSection
id="faq"
title="Häufig gestellte Fragen"
subtitle="Alles was du über ManaDeck wissen musst"
faqs={faqs}
/>
<CTASection
id="download"
title="Bereit, smarter zu lernen?"
subtitle="Lade ManaDeck 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>
<CTASection
id="download"
title="Bereit, smarter zu lernen?"
subtitle="Lade ManaDeck 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>
<!-- 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 />
<Footer />
</Layout>

View file

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

View file

@ -1,11 +1,11 @@
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": ["-y", "@supabase/mcp-server-supabase@latest", "--project-ref=vksoodohrbjwyloitvsz"],
"env": {
"SUPABASE_ACCESS_TOKEN": "sbp_3622a96f728711cd06b113c17f77f84d02ff8fb2"
}
}
}
"mcpServers": {
"supabase": {
"command": "npx",
"args": ["-y", "@supabase/mcp-server-supabase@latest", "--project-ref=vksoodohrbjwyloitvsz"],
"env": {
"SUPABASE_ACCESS_TOKEN": "sbp_3622a96f728711cd06b113c17f77f84d02ff8fb2"
}
}
}
}

View file

@ -9,14 +9,16 @@ Manadeck is a React Native/Expo application using Expo Router for navigation, Ty
## Essential Commands
### Development
```bash
npm start # Start Expo dev server with dev client
npm run ios # Run on iOS simulator
npm run android # Run on Android emulator
npm run android # Run on Android emulator
npm run web # Run in web browser
```
### Build & Deploy
```bash
npm run build:dev # Build development build with EAS
npm run build:preview # Build preview build with EAS
@ -25,6 +27,7 @@ npm run prebuild # Generate native projects
```
### Code Quality
```bash
npm run lint # Run ESLint and Prettier checks
npm run format # Auto-fix ESLint and format with Prettier
@ -33,6 +36,7 @@ npm run format # Auto-fix ESLint and format with Prettier
## Architecture & Key Patterns
### File Structure
- **app/**: Expo Router pages with file-based routing
- `(tabs)/`: Tab navigation screens
- `_layout.tsx`: Root layout with navigation stack
@ -43,27 +47,33 @@ npm run format # Auto-fix ESLint and format with Prettier
- **assets/**: Static assets (images, icons)
### Navigation Pattern
Uses Expo Router v5 with file-based routing:
- Tab navigation defined in `app/(tabs)/_layout.tsx`
- Stack navigation in `app/_layout.tsx`
- Typed routes enabled via `experiments.typedRoutes` in app.json
### Styling Approach
- NativeWind (Tailwind CSS for React Native) configured via `tailwind.config.js`
- Global styles imported in `app/_layout.tsx` via `global.css`
- Components use className prop for Tailwind classes
### State Management
- Zustand store pattern in `store/store.ts`
- Create typed stores with actions and selectors
- Access via hooks (e.g., `useStore`)
### Backend Integration
- Supabase client configured in `utils/supabase.ts`
- Uses AsyncStorage for session persistence
- Environment variables: `EXPO_PUBLIC_SUPABASE_URL` and `EXPO_PUBLIC_SUPABASE_ANON_KEY`
### TypeScript Configuration
- Strict mode enabled
- Path alias `~/*` configured for absolute imports
- Type definitions for Expo and React Native included
@ -71,27 +81,34 @@ Uses Expo Router v5 with file-based routing:
## Development Guidelines
### Component Creation
When creating new components:
1. Place in `components/` directory
2. Use TypeScript with proper prop typing
3. Apply NativeWind classes via className prop
4. Follow existing naming conventions (PascalCase for components)
### Screen/Page Creation
For new screens:
1. Add file in appropriate directory under `app/`
2. Export default React component
3. Configure navigation options if needed
4. Import and use existing UI components
### Supabase Integration
When working with Supabase:
1. Import client from `utils/supabase.ts`
2. Handle authentication state with AsyncStorage
3. Use environment variables for configuration
4. Follow RLS (Row Level Security) best practices
### Code Style
- ESLint configured with Expo preset
- Prettier for formatting with Tailwind plugin
- React display-name rule disabled
@ -100,19 +117,24 @@ When working with Supabase:
## Environment Setup
### Required Environment Variables
Create a `.env` or `.env.local` file:
```
EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
```
### EAS Configuration
Project configured with EAS Build:
- Project ID: `6cb9cf81-a4d5-4c72-b57d-1be3da8eba35`
- Three build profiles: development, preview, production
- Development builds include dev client
### Platform-Specific Setup
- iOS: Bundle ID `com.tilljs.manadeck`
- Android: Package `com.tilljs.manadeck`
- Uses Expo development build for custom native code
- Uses Expo development build for custom native code

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ Die Manadeck-App nutzt die **Native Tabs** von Expo Router (SDK 54+), um eine na
## Aktuelle Implementierung
### Standort
`app/(tabs)/_layout.tsx`
### Konfiguration
@ -15,35 +16,35 @@ Die Manadeck-App nutzt die **Native Tabs** von Expo Router (SDK 54+), um eine na
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="automatic">
<NativeTabs.Trigger name="decks">
<Label>Decks</Label>
<Icon
sf={{ default: 'square.stack.3d.up', selected: 'square.stack.3d.up.fill' }}
drawable="ic_albums"
/>
</NativeTabs.Trigger>
return (
<NativeTabs minimizeBehavior="automatic">
<NativeTabs.Trigger name="decks">
<Label>Decks</Label>
<Icon
sf={{ default: 'square.stack.3d.up', selected: 'square.stack.3d.up.fill' }}
drawable="ic_albums"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<Label>Entdecken</Label>
<Icon sf={{ default: 'safari', selected: 'safari.fill' }} drawable="ic_explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<Label>Entdecken</Label>
<Icon sf={{ default: 'safari', selected: 'safari.fill' }} drawable="ic_explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="progress">
<Label>Fortschritt</Label>
<Icon
sf={{ default: 'chart.line.uptrend.xyaxis', selected: 'chart.line.uptrend.xyaxis' }}
drawable="ic_trending_up"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="progress">
<Label>Fortschritt</Label>
<Icon
sf={{ default: 'chart.line.uptrend.xyaxis', selected: 'chart.line.uptrend.xyaxis' }}
drawable="ic_trending_up"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="profile">
<Label>Profil</Label>
<Icon sf={{ default: 'person', selected: 'person.fill' }} drawable="ic_person" />
</NativeTabs.Trigger>
</NativeTabs>
);
<NativeTabs.Trigger name="profile">
<Label>Profil</Label>
<Icon sf={{ default: 'person', selected: 'person.fill' }} drawable="ic_person" />
</NativeTabs.Trigger>
</NativeTabs>
);
}
```
@ -55,16 +56,17 @@ Die `minimizeBehavior` Property steuert, wie sich die Tab-Leiste beim Scrollen v
### Verfügbare Optionen
| Option | Beschreibung |
|--------|--------------|
| `'automatic'` | **[AKTUELL IN APP]** iOS entscheidet automatisch, wann die Tab-Leiste minimiert wird. Dies passt sich dem nativen iOS-Kontext an. |
| `'never'` | Tab-Leiste bleibt immer sichtbar und wird nie minimiert. |
| `'onScrollDown'` | Tab-Leiste wird beim Runterscrollen minimiert und beim Hochscrollen wieder angezeigt. |
| `'onScrollUp'` | Tab-Leiste wird beim Hochscrollen minimiert (seltener verwendet). |
| Option | Beschreibung |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `'automatic'` | **[AKTUELL IN APP]** iOS entscheidet automatisch, wann die Tab-Leiste minimiert wird. Dies passt sich dem nativen iOS-Kontext an. |
| `'never'` | Tab-Leiste bleibt immer sichtbar und wird nie minimiert. |
| `'onScrollDown'` | Tab-Leiste wird beim Runterscrollen minimiert und beim Hochscrollen wieder angezeigt. |
| `'onScrollUp'` | Tab-Leiste wird beim Hochscrollen minimiert (seltener verwendet). |
### Unsere Wahl: `automatic`
Wir verwenden `minimizeBehavior="automatic"`, weil:
- iOS das Verhalten optimal an den Kontext anpasst
- Es sich nativ anfühlt und konsistent mit anderen iOS-Apps ist
- Keine manuelle Konfiguration für verschiedene Scroll-Szenarien nötig ist
@ -72,12 +74,14 @@ Wir verwenden `minimizeBehavior="automatic"`, weil:
## Systemanforderungen
### iOS
- **Minimum:** iOS 26 (für `minimizeBehavior`)
- **Xcode:** Version 26 oder höher
- **Expo SDK:** 54 oder höher
- **Status:** ✅ Produktionsreif (experimentelle API)
### Android
- **Status:** 🚧 In Entwicklung
- Native Tab Minimize Behavior ist noch nicht verfügbar
- Geplante Unterstützung in zukünftigen Expo-Versionen
@ -96,7 +100,7 @@ Wir verwenden `minimizeBehavior="automatic"`, weil:
```tsx
<NativeTabs minimizeBehavior="automatic" disableTransparentOnScrollEdge>
{/* Tabs */}
{/* Tabs */}
</NativeTabs>
```
@ -109,16 +113,19 @@ Wir verwenden `minimizeBehavior="automatic"`, weil:
## Best Practices für Manadeck
### Icons
- **iOS:** SF Symbols verwenden (höhere Qualität, native Optik)
- **Android:** Drawables verwenden
- Immer `default` und `selected` States definieren für besseres UX
### Scroll-Verhalten
- Verwende ScrollView oder ähnliche Komponenten in Tab-Screens
- Bei FlatList-Problemen: `disableTransparentOnScrollEdge` aktivieren
- Teste das Verhalten auf echten Geräten (Simulator kann abweichen)
### Entwicklung
```bash
# Development Server starten
npm start
@ -138,10 +145,10 @@ npm run android
## Änderungshistorie
| Datum | Änderung | Version |
|-------|----------|---------|
| 2025-10-20 | `minimizeBehavior="automatic"` hinzugefügt | SDK 54 |
| 2025-09 | Initiale Native Tabs Implementierung | SDK 54 |
| Datum | Änderung | Version |
| ---------- | ------------------------------------------ | ------- |
| 2025-10-20 | `minimizeBehavior="automatic"` hinzugefügt | SDK 54 |
| 2025-09 | Initiale Native Tabs Implementierung | SDK 54 |
---

View file

@ -9,68 +9,76 @@ Die Manadeck-Anwendung verwendet **Supabase** (PostgreSQL) als Backend-Datenbank
### Tabellen
#### 1. **profiles**
Speichert Benutzerprofile und Einstellungen.
| Spalte | Typ | Beschreibung | Constraints |
|--------|-----|--------------|-------------|
| `id` | uuid | Benutzer-ID (Referenz zu auth.users) | PRIMARY KEY, NOT NULL |
| `username` | varchar | Eindeutiger Benutzername | NOT NULL, UNIQUE |
| `display_name` | varchar | Anzeigename des Benutzers | NULL |
| `avatar_url` | text | URL zum Profilbild | NULL |
| `bio` | text | Biografie/Beschreibung | NULL |
| `preferences` | jsonb | Benutzereinstellungen als JSON | DEFAULT '{}' |
| `created_at` | timestamptz | Erstellungszeitpunkt | DEFAULT now() |
| `updated_at` | timestamptz | Letzte Aktualisierung | DEFAULT now() |
| Spalte | Typ | Beschreibung | Constraints |
| -------------- | ----------- | ------------------------------------ | --------------------- |
| `id` | uuid | Benutzer-ID (Referenz zu auth.users) | PRIMARY KEY, NOT NULL |
| `username` | varchar | Eindeutiger Benutzername | NOT NULL, UNIQUE |
| `display_name` | varchar | Anzeigename des Benutzers | NULL |
| `avatar_url` | text | URL zum Profilbild | NULL |
| `bio` | text | Biografie/Beschreibung | NULL |
| `preferences` | jsonb | Benutzereinstellungen als JSON | DEFAULT '{}' |
| `created_at` | timestamptz | Erstellungszeitpunkt | DEFAULT now() |
| `updated_at` | timestamptz | Letzte Aktualisierung | DEFAULT now() |
**Beziehungen:**
- Foreign Key zu `auth.users(id)` über `id`
#### 2. **decks**
Verwaltet Kartenstapel/Sammlungen von Lernkarten.
| Spalte | Typ | Beschreibung | Constraints |
|--------|-----|--------------|-------------|
| `id` | uuid | Eindeutige Deck-ID | PRIMARY KEY, DEFAULT gen_random_uuid() |
| `user_id` | uuid | Besitzer des Decks | NOT NULL, FK zu auth.users |
| `title` | varchar | Titel des Decks | NOT NULL |
| `description` | text | Beschreibung des Decks | NULL |
| `cover_image_url` | text | URL zum Cover-Bild | NULL |
| `is_public` | boolean | Öffentlich sichtbar? | DEFAULT false |
| `settings` | jsonb | Deck-spezifische Einstellungen | DEFAULT '{}' |
| `tags` | text[] | Array von Tags | DEFAULT '{}' |
| `metadata` | jsonb | Zusätzliche Metadaten | DEFAULT '{}' |
| `created_at` | timestamptz | Erstellungszeitpunkt | DEFAULT now() |
| `updated_at` | timestamptz | Letzte Aktualisierung | DEFAULT now() |
| Spalte | Typ | Beschreibung | Constraints |
| ----------------- | ----------- | ------------------------------ | -------------------------------------- |
| `id` | uuid | Eindeutige Deck-ID | PRIMARY KEY, DEFAULT gen_random_uuid() |
| `user_id` | uuid | Besitzer des Decks | NOT NULL, FK zu auth.users |
| `title` | varchar | Titel des Decks | NOT NULL |
| `description` | text | Beschreibung des Decks | NULL |
| `cover_image_url` | text | URL zum Cover-Bild | NULL |
| `is_public` | boolean | Öffentlich sichtbar? | DEFAULT false |
| `settings` | jsonb | Deck-spezifische Einstellungen | DEFAULT '{}' |
| `tags` | text[] | Array von Tags | DEFAULT '{}' |
| `metadata` | jsonb | Zusätzliche Metadaten | DEFAULT '{}' |
| `created_at` | timestamptz | Erstellungszeitpunkt | DEFAULT now() |
| `updated_at` | timestamptz | Letzte Aktualisierung | DEFAULT now() |
**Beziehungen:**
- Foreign Key zu `auth.users(id)` über `user_id`
- One-to-Many Beziehung zu `cards`
#### 3. **cards**
Einzelne Lernkarten innerhalb der Decks.
| Spalte | Typ | Beschreibung | Constraints |
|--------|-----|--------------|-------------|
| `id` | uuid | Eindeutige Karten-ID | PRIMARY KEY, DEFAULT gen_random_uuid() |
| `deck_id` | uuid | Zugehöriges Deck | NOT NULL, FK zu decks |
| `position` | integer | Position innerhalb des Decks | NOT NULL |
| `title` | varchar | Titel/Überschrift der Karte | NULL |
| `content` | jsonb | Karteninhalt (strukturiert) | DEFAULT '{}' |
| `card_type` | card_type (enum) | Typ der Karte | NOT NULL, DEFAULT 'text' |
| `ai_model` | varchar | Verwendetes AI-Modell | NULL |
| `ai_prompt` | text | AI-Prompt für Generierung | NULL |
| `version` | integer | Versionsnummer | DEFAULT 1 |
| `is_favorite` | boolean | Als Favorit markiert? | DEFAULT false |
| `created_at` | timestamptz | Erstellungszeitpunkt | DEFAULT now() |
| `updated_at` | timestamptz | Letzte Aktualisierung | DEFAULT now() |
| Spalte | Typ | Beschreibung | Constraints |
| ------------- | ---------------- | ---------------------------- | -------------------------------------- |
| `id` | uuid | Eindeutige Karten-ID | PRIMARY KEY, DEFAULT gen_random_uuid() |
| `deck_id` | uuid | Zugehöriges Deck | NOT NULL, FK zu decks |
| `position` | integer | Position innerhalb des Decks | NOT NULL |
| `title` | varchar | Titel/Überschrift der Karte | NULL |
| `content` | jsonb | Karteninhalt (strukturiert) | DEFAULT '{}' |
| `card_type` | card_type (enum) | Typ der Karte | NOT NULL, DEFAULT 'text' |
| `ai_model` | varchar | Verwendetes AI-Modell | NULL |
| `ai_prompt` | text | AI-Prompt für Generierung | NULL |
| `version` | integer | Versionsnummer | DEFAULT 1 |
| `is_favorite` | boolean | Als Favorit markiert? | DEFAULT false |
| `created_at` | timestamptz | Erstellungszeitpunkt | DEFAULT now() |
| `updated_at` | timestamptz | Letzte Aktualisierung | DEFAULT now() |
**Beziehungen:**
- Foreign Key zu `decks(id)` über `deck_id`
### Custom Types
#### card_type (Enum)
Definiert die verschiedenen Kartentypen:
- `text` - Reine Textkarte
- `mixed` - Gemischter Inhalt
- `quiz` - Quiz-Karte
@ -82,29 +90,29 @@ Alle Tabellen haben RLS aktiviert mit folgenden Policies:
### profiles Policies
| Policy | Operation | Beschreibung |
|--------|-----------|--------------|
| "Profiles are viewable by everyone" | SELECT | Alle können Profile einsehen |
| "Users can insert own profile" | INSERT | Authentifizierte Benutzer können eigenes Profil anlegen |
| "Users can update own profile" | UPDATE | Benutzer können nur eigenes Profil bearbeiten |
| Policy | Operation | Beschreibung |
| ----------------------------------- | --------- | ------------------------------------------------------- |
| "Profiles are viewable by everyone" | SELECT | Alle können Profile einsehen |
| "Users can insert own profile" | INSERT | Authentifizierte Benutzer können eigenes Profil anlegen |
| "Users can update own profile" | UPDATE | Benutzer können nur eigenes Profil bearbeiten |
### decks Policies
| Policy | Operation | Beschreibung |
|--------|-----------|--------------|
| "View public decks and own decks" | SELECT | Öffentliche Decks und eigene Decks sind sichtbar |
| "Users can create own decks" | INSERT | Authentifizierte Benutzer können Decks erstellen |
| "Users can update own decks" | UPDATE | Nur eigene Decks können bearbeitet werden |
| "Users can delete own decks" | DELETE | Nur eigene Decks können gelöscht werden |
| Policy | Operation | Beschreibung |
| --------------------------------- | --------- | ------------------------------------------------ |
| "View public decks and own decks" | SELECT | Öffentliche Decks und eigene Decks sind sichtbar |
| "Users can create own decks" | INSERT | Authentifizierte Benutzer können Decks erstellen |
| "Users can update own decks" | UPDATE | Nur eigene Decks können bearbeitet werden |
| "Users can delete own decks" | DELETE | Nur eigene Decks können gelöscht werden |
### cards Policies
| Policy | Operation | Beschreibung |
|--------|-----------|--------------|
| "View cards from accessible decks" | SELECT | Karten aus öffentlichen oder eigenen Decks sind sichtbar |
| "Users can create cards in own decks" | INSERT | Karten können nur in eigenen Decks erstellt werden |
| "Users can update cards in own decks" | UPDATE | Karten in eigenen Decks können bearbeitet werden |
| "Users can delete cards in own decks" | DELETE | Karten in eigenen Decks können gelöscht werden |
| Policy | Operation | Beschreibung |
| ------------------------------------- | --------- | -------------------------------------------------------- |
| "View cards from accessible decks" | SELECT | Karten aus öffentlichen oder eigenen Decks sind sichtbar |
| "Users can create cards in own decks" | INSERT | Karten können nur in eigenen Decks erstellt werden |
| "Users can update cards in own decks" | UPDATE | Karten in eigenen Decks können bearbeitet werden |
| "Users can delete cards in own decks" | DELETE | Karten in eigenen Decks können gelöscht werden |
## Aktivierte Extensions
@ -143,7 +151,9 @@ cards
## Verwendung in der Anwendung
### Supabase Client-Konfiguration
Die Anwendung verwendet den Supabase JavaScript Client (`utils/supabase.ts`) mit:
- **AsyncStorage** für Session-Persistenz
- Umgebungsvariablen für Konfiguration:
- `EXPO_PUBLIC_SUPABASE_URL`
@ -170,4 +180,4 @@ Die Anwendung verwendet den Supabase JavaScript Client (`utils/supabase.ts`) mit
2. **Partitionierung**: Bei großen Datenmengen könnte Tabellen-Partitionierung sinnvoll sein
3. **Materialized Views**: Für komplexe Abfragen öffentlicher Decks
4. **Full-Text Search**: Implementierung mit PostgreSQL FTS für Deck- und Kartensuche
5. **Caching**: Redis-Integration für häufig abgerufene öffentliche Inhalte
5. **Caching**: Redis-Integration für häufig abgerufene öffentliche Inhalte

View file

@ -7,6 +7,7 @@ Diese Anleitung beschreibt den Upgrade-Prozess von Expo SDK 53 auf SDK 54 für d
## Wichtige Änderungen in SDK 54
### Hauptversionen
- **React Native**: 0.79.5 → 0.81.x
- **React**: 19.0.0 → 19.1.x
- **Reanimated**: v3 → v4 (optional)
@ -37,6 +38,7 @@ Diese Anleitung beschreibt den Upgrade-Prozess von Expo SDK 53 auf SDK 54 für d
### 1. Vorbereitung
Erstelle ein Backup deines Projekts:
```bash
git add .
git commit -m "Backup vor SDK 54 Upgrade"
@ -59,6 +61,7 @@ npx expo-doctor@latest
### 3. React und React Native aktualisieren
Da SDK 54 React Native 0.81 und React 19.1 benötigt:
```bash
npm install react@19.1.0 react-native@0.81.x
```
@ -110,6 +113,7 @@ npx expo install \
Falls das Projekt `expo-file-system` nutzt:
**Quick Migration (Legacy API behalten):**
```typescript
// Alt:
import * as FileSystem from 'expo-file-system';
@ -119,6 +123,7 @@ import * as FileSystem from 'expo-file-system/legacy';
```
**Oder zur neuen API migrieren:**
```typescript
// Neue API verwenden
import * as FileSystem from 'expo-file-system';
@ -127,6 +132,7 @@ import * as FileSystem from 'expo-file-system';
### 6. Native Projekte aktualisieren
**Wenn Continuous Native Generation verwendet wird:**
```bash
# Alte native Verzeichnisse löschen
rm -rf android ios
@ -136,6 +142,7 @@ npx expo prebuild --clean
```
**Wenn eigene native Projekte vorhanden sind:**
```bash
# iOS Pods aktualisieren
cd ios && pod install && cd ..
@ -147,19 +154,20 @@ cd ios && pod install && cd ..
### 7. EAS Build Configuration
Update `eas.json` falls nötig:
```json
{
"build": {
"development": {
"node": "20.19.1"
},
"preview": {
"node": "20.19.1"
},
"production": {
"node": "20.19.1"
}
}
"build": {
"development": {
"node": "20.19.1"
},
"preview": {
"node": "20.19.1"
},
"production": {
"node": "20.19.1"
}
}
}
```
@ -199,6 +207,7 @@ npm run build:dev
## Troubleshooting
### Problem: Metro Bundler Fehler
```bash
# Metro Cache löschen
npx expo start --clear
@ -206,14 +215,17 @@ rm -rf node_modules/.cache/metro
```
### Problem: iOS Build schlägt fehl
- Xcode 16.1 oder höher erforderlich
- iOS Deployment Target prüfen
### Problem: Reanimated Fehler
- Bei Problemen mit v4: Bei v3 bleiben
- Babel Config prüfen (babel-preset-expo handled das automatisch)
### Problem: TypeScript Fehler
```bash
# TypeScript neu konfigurieren
npx expo customize tsconfig.json
@ -222,6 +234,7 @@ npx expo customize tsconfig.json
## Rollback bei Problemen
Falls das Upgrade fehlschlägt:
```bash
git checkout backup-sdk-53
npm install
@ -237,6 +250,7 @@ npm install
## Nächste Schritte nach dem Upgrade
1. **New Architecture aktivieren** (empfohlen für SDK 54):
```bash
npx expo prebuild --clean
```
@ -250,6 +264,7 @@ npm install
## Support
Bei Problemen:
- [Expo Discord](https://chat.expo.dev)
- [GitHub Issues](https://github.com/expo/expo/issues)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/expo)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/expo)

View file

@ -1,7 +1,9 @@
# Manadeck - Nächste Implementierungsschritte
## 🎯 Aktueller Status
✅ **Phase 1 abgeschlossen:**
- Authentifizierung funktioniert (Login, Register, Logout)
- Basis UI-Komponenten erstellt
- Navigation mit Tab Bar implementiert
@ -11,6 +13,7 @@
## 📋 Phase 2: Deck Management (Priorität: HOCH)
### 2.1 Deck Liste mit echten Daten
- [ ] Decks aus Datenbank in "Meine Decks" Tab anzeigen
- [ ] Deck-Karten mit tatsächlichen Daten
- [ ] Filter für Favoriten funktionsfähig machen
@ -18,6 +21,7 @@
- [ ] Empty State nur wenn wirklich keine Decks vorhanden
### 2.2 Deck Detail View
- [ ] `/deck/[id].tsx` Screen erstellen
- [ ] Deck Informationen anzeigen (Titel, Beschreibung, Tags)
- [ ] Karten-Liste des Decks anzeigen
@ -25,6 +29,7 @@
- [ ] Header mit Cover Image oder Gradient
### 2.3 Deck Bearbeitung
- [ ] `/deck/[id]/edit.tsx` Screen
- [ ] Deck-Informationen updaten
- [ ] Cover Image Upload (Supabase Storage)
@ -32,6 +37,7 @@
- [ ] Öffentlich/Privat Toggle
### 2.4 Deck Sharing & Discovery
- [ ] Öffentliche Decks in Explore Tab anzeigen
- [ ] Suche nach öffentlichen Decks
- [ ] Deck kopieren/forken Funktionalität
@ -40,12 +46,14 @@
## 📝 Phase 3: Karten-System (Priorität: HOCH)
### 3.1 Card CRUD Operationen
- [ ] Card Store erstellen (`cardStore.ts`)
- [ ] Karten zu Deck hinzufügen
- [ ] Karten bearbeiten/löschen
- [ ] Karten-Positionen speichern
### 3.2 Card Editor
- [ ] `/card/create.tsx` mit Deck-Kontext
- [ ] `/card/edit/[id].tsx` für Bearbeitung
- [ ] Rich Text Editor für Karten-Content
@ -56,6 +64,7 @@
- **Mixed**: Kombiniert verschiedene Elemente
### 3.3 Card Management UI
- [ ] Karten-Liste in Deck Detail View
- [ ] Sortierung per Drag & Drop
- [ ] Bulk-Aktionen (Mehrere löschen/verschieben)
@ -63,6 +72,7 @@
- [ ] Favoriten markieren
### 3.4 AI Integration (Optional)
- [ ] AI-Generierung für Karten
- [ ] Prompt-Templates für verschiedene Lernthemen
- [ ] Batch-Generierung mehrerer Karten
@ -70,6 +80,7 @@
## 🎓 Phase 4: Lern-Features (Priorität: MITTEL)
### 4.1 Study Session
- [ ] `/study/session/[deckId].tsx` implementieren
- [ ] Karten-Navigation (Vor/Zurück)
- [ ] Flip-Animation für Flashcards
@ -77,12 +88,14 @@
- [ ] Session-Timer
### 4.2 Lernmodi
- [ ] **Browse Mode**: Einfaches Durchblättern
- [ ] **Practice Mode**: Mit Selbstbewertung
- [ ] **Quiz Mode**: Mit Punktzahl
- [ ] **Spaced Repetition**: Algorithmus implementieren
### 4.3 Fortschritts-Tracking
- [ ] Study Store für Lernfortschritt
- [ ] Karten als "gelernt" markieren
- [ ] Schwierigkeitsgrad pro Karte
@ -90,6 +103,7 @@
- [ ] Statistiken speichern (richtig/falsch)
### 4.4 Statistiken & Visualisierung
- [ ] Lernstatistik Dashboard
- [ ] Streak-Counter
- [ ] Fortschritts-Charts
@ -99,12 +113,14 @@
## 🔧 Phase 5: Optimierungen (Priorität: NIEDRIG)
### 5.1 Performance
- [ ] Lazy Loading für große Deck-Listen
- [ ] Image Optimization & Caching
- [ ] Offline-Support mit AsyncStorage
- [ ] Optimistische Updates
### 5.2 UX Verbesserungen
- [ ] Swipe-Gesten für Karten
- [ ] Haptic Feedback
- [ ] Sound-Effekte (optional)
@ -112,6 +128,7 @@
- [ ] Animations & Transitions
### 5.3 Social Features
- [ ] Benutzerprofile erweitern
- [ ] Deck-Bewertungen
- [ ] Kommentare zu öffentlichen Decks
@ -121,18 +138,21 @@
## 🚀 Phase 6: Premium Features (Zukunft)
### 6.1 Erweiterte AI Features
- [ ] GPT-4 Integration für bessere Karten
- [ ] Automatische Zusammenfassungen
- [ ] Lernpfad-Generierung
- [ ] Personalisierte Empfehlungen
### 6.2 Kollaboration
- [ ] Gemeinsame Decks
- [ ] Real-time Bearbeitung
- [ ] Gruppen-Lernsessions
- [ ] Lehrer-Schüler Modus
### 6.3 Export & Integration
- [ ] Anki Export/Import
- [ ] PDF Export
- [ ] CSV Import für Bulk-Karten
@ -141,12 +161,14 @@
## 🛠 Technische Schulden
### Refactoring Needs
- [ ] Error Boundaries hinzufügen
- [ ] Loading States vereinheitlichen
- [ ] Form Validation Library (react-hook-form)
- [ ] Tests schreiben (Jest + React Testing Library)
### Infrastructure
- [ ] CI/CD Pipeline setup
- [ ] Error Tracking (Sentry)
- [ ] Analytics Integration
@ -155,21 +177,25 @@
## 📊 Priorisierung
### Sofort (Diese Woche)
1. Deck Liste mit echten Daten
2. Deck Detail View
3. Basis Card CRUD
### Kurzfristig (2-3 Wochen)
1. Card Editor mit allen Typen
2. Basis Study Mode
3. Einfaches Fortschritts-Tracking
### Mittelfristig (1-2 Monate)
1. Spaced Repetition
2. Statistiken Dashboard
3. Öffentliche Decks & Suche
### Langfristig (3+ Monate)
1. AI Features
2. Social Features
3. Premium Funktionen
@ -177,13 +203,15 @@
## 🎯 MVP Definition
**Minimum Viable Product beinhaltet:**
- ✅ User Auth (FERTIG)
- ⏳ Deck CRUD (IN ARBEIT)
- ⏳ Card CRUD
- ⏳ Card CRUD
- ⏳ Basis Study Mode
- ⏳ Einfacher Fortschritt
**Nice-to-have für MVP:**
- Öffentliche Decks
- Basis-Statistiken
- Dark Mode
@ -213,4 +241,4 @@
5. **Study Mode Basis** (`app/study/session/[deckId].tsx`)
- Karten durchblättern
- Flip Animation
- Progress tracking
- Progress tracking

View file

@ -19,11 +19,13 @@ Es gibt mehrere Wege, ein neues Deck zu erstellen:
Beim Erstellen eines neuen Decks musst du folgende Informationen angeben:
#### Grunddaten
- **Titel**: Ein aussagekräftiger Name für dein Deck
- **Beschreibung**: Eine kurze Erklärung, worum es in dem Deck geht
- **Kategorie**: Wähle eine passende Kategorie (Sprachen, Wissenschaft, Mathematik, etc.)
#### Sichtbarkeits-Einstellungen
- **Privat**: Nur du kannst das Deck sehen und verwenden
- **Öffentlich**: Andere Benutzer können dein Deck in der Explore-Sektion finden
@ -32,6 +34,7 @@ Beim Erstellen eines neuen Decks musst du folgende Informationen angeben:
Nach der Deck-Erstellung kannst du Karten hinzufügen:
#### Kartentypen
Manadeck unterstützt verschiedene Kartentypen:
1. **Text-Karten**: Klassische Vorderseite/Rückseite Karten
@ -40,6 +43,7 @@ Manadeck unterstützt verschiedene Kartentypen:
4. **Multiple-Choice**: Karten mit Auswahlmöglichkeiten
#### Karten-Erstellung
- **Vorderseite**: Die Frage oder der Begriff
- **Rückseite**: Die Antwort oder Erklärung
- **Hinweise**: Optionale Hilfestellungen
@ -50,22 +54,26 @@ Manadeck unterstützt verschiedene Kartentypen:
Manadeck bietet KI-Features zur Deck-Erstellung:
#### Smart Card Creator
- Automatische Generierung von Karten basierend auf einem Thema
- KI schlägt passende Fragen und Antworten vor
- Unterstützt verschiedene Schwierigkeitsgrade
#### Image Card Creator
- Erstellung von Bildkarten mit KI-generierten oder hochgeladenen Bildern
- Automatische Beschreibungen und Fragen zu Bildern
### 5. Deck-Verwaltung
#### Einstellungen
- **Metadaten bearbeiten**: Titel, Beschreibung, Kategorie ändern
- **Sichtbarkeit**: Zwischen privat und öffentlich wechseln
- **Tags**: Deck-Tags für bessere Organisation
#### Organisation
- **Favoriten**: Wichtige Decks als Favoriten markieren
- **Sortierung**: Decks nach verschiedenen Kriterien sortieren
- **Suchfunktion**: Decks schnell finden
@ -73,18 +81,21 @@ Manadeck bietet KI-Features zur Deck-Erstellung:
## Best Practices
### Deck-Design
1. **Klare Titel**: Verwende aussagekräftige und spezifische Titel
2. **Gute Beschreibungen**: Erkläre kurz, was gelernt wird und für wen das Deck geeignet ist
3. **Konsistente Struktur**: Halte das Format der Karten einheitlich
4. **Angemessene Größe**: 20-50 Karten pro Deck für optimales Lernen
### Karten-Gestaltung
1. **Eine Idee pro Karte**: Jede Karte sollte nur ein Konzept behandeln
2. **Klare Formulierungen**: Vermeide mehrdeutige Fragen
3. **Sinnvolle Reihenfolge**: Ordne Karten logisch an
4. **Regelmäßige Updates**: Überarbeite und verbessere deine Karten
### Öffentliche Decks
Wenn du dein Deck öffentlich machst:
1. **Qualität sicherstellen**: Überprüfe alle Karten auf Richtigkeit
@ -95,7 +106,9 @@ Wenn du dein Deck öffentlich machst:
## Technische Details
### Datenbankstruktur
Decks werden in der `decks` Tabelle gespeichert mit folgenden Feldern:
- `id`: Eindeutige ID
- `title`: Deck-Titel
- `description`: Beschreibung
@ -106,6 +119,7 @@ Decks werden in der `decks` Tabelle gespeichert mit folgenden Feldern:
- `metadata`: Zusätzliche Informationen (JSON)
### API-Endpunkte
- `POST /api/decks`: Neues Deck erstellen
- `PUT /api/decks/:id`: Deck bearbeiten
- `DELETE /api/decks/:id`: Deck löschen
@ -130,10 +144,11 @@ Decks werden in der `decks` Tabelle gespeichert mit folgenden Feldern:
## Support
Bei Problemen oder Fragen:
- Überprüfe diese Dokumentation
- Schaue in die FAQ-Sektion
- Kontaktiere den Support über die App
---
*Letzte Aktualisierung: 2025-09-24*
_Letzte Aktualisierung: 2025-09-24_

View file

@ -7,24 +7,28 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## 🌍 Sprachen
### Deutsch für Anfänger
- **Zielgruppe**: Deutschlernende (A1-A2 Niveau)
- **Inhalt**: Grundwortschatz, häufige Phrasen, Zahlen, Farben
- **Kartenzahl**: ~100 Karten
- **Besonderheiten**: Audio-Karten für Aussprache
### English Business Vocabulary
- **Zielgruppe**: Berufstätige, Studenten
- **Inhalt**: Geschäftsvokabular, Meeting-Phrasen, E-Mail-Formulierungen
- **Kartenzahl**: ~75 Karten
- **Besonderheiten**: Kontextuelle Beispiele
### Französische Verben (Présent)
- **Zielgruppe**: Französischlernende (A2-B1)
- **Inhalt**: Konjugation häufiger Verben im Präsens
- **Kartenzahl**: ~80 Karten
- **Besonderheiten**: Irregular verbs highlighted
### Spanisch: Reisevokabular
- **Zielgruppe**: Reisende, Touristen
- **Inhalt**: Hotel, Restaurant, Transport, Notfälle
- **Kartenzahl**: ~60 Karten
@ -33,24 +37,28 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## 🧬 Wissenschaft
### Grundlagen der Chemie
- **Zielgruppe**: Schüler, Studienanfänger
- **Inhalt**: Periodensystem, chemische Formeln, Grundreaktionen
- **Kartenzahl**: ~90 Karten
- **Besonderheiten**: Strukturformeln als Bilder
### Anatomie: Skelett-System
- **Zielgruppe**: Medizinstudenten, Interessierte
- **Inhalt**: Knochen, Gelenke, anatomische Begriffe
- **Kartenzahl**: ~120 Karten
- **Besonderheiten**: Anatomische Diagramme
### Physik: Mechanik Formeln
- **Zielgruppe**: Schüler, Studenten
- **Inhalt**: Grundformeln der Mechanik, Einheiten, Konstanten
- **Kartenzahl**: ~50 Karten
- **Besonderheiten**: Formel-Karten mit Erklärungen
### Biologie: Zellbiologie
- **Zielgruppe**: Schüler, Biologiestudenten
- **Inhalt**: Zellorganellen, Funktionen, Prozesse
- **Kartenzahl**: ~70 Karten
@ -59,24 +67,28 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## 🧮 Mathematik
### Grundrechenarten für Kinder
- **Zielgruppe**: Grundschüler (6-10 Jahre)
- **Inhalt**: Addition, Subtraktion, kleines Einmaleins
- **Kartenzahl**: ~100 Karten
- **Besonderheiten**: Bunte, kinderfreundliche Gestaltung
### Algebra: Grundformeln
- **Zielgruppe**: Schüler (7.-10. Klasse)
- **Inhalt**: Binomische Formeln, Gleichungen, Terme
- **Kartenzahl**: ~45 Karten
- **Besonderheiten**: Schritt-für-Schritt Lösungen
### Geometrie: Flächenberechnung
- **Zielgruppe**: Schüler, Handwerker
- **Inhalt**: Formeln für Kreis, Dreieck, Rechteck, etc.
- **Kartenzahl**: ~30 Karten
- **Besonderheiten**: Visualisierungen der Formeln
### Statistik Grundlagen
- **Zielgruppe**: Studenten, Berufstätige
- **Inhalt**: Mittelwert, Median, Standardabweichung
- **Kartenzahl**: ~40 Karten
@ -85,18 +97,21 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## 🏛️ Geschichte
### Deutsche Geschichte: 20. Jahrhundert
- **Zielgruppe**: Schüler, Geschichtsinteressierte
- **Inhalt**: Wichtige Ereignisse, Personen, Daten
- **Kartenzahl**: ~80 Karten
- **Besonderheiten**: Historische Fotos
### Antikes Rom
- **Zielgruppe**: Geschichtsstudenten, Interessierte
- **Inhalt**: Kaiser, Schlachten, Kultur, Architektur
- **Kartenzahl**: ~100 Karten
- **Besonderheiten**: Karten und Abbildungen
### Zweiter Weltkrieg: Schlüsselereignisse
- **Zielgruppe**: Oberstufenschüler, Studenten
- **Inhalt**: Wichtige Schlachten, Wendepunkte, Personen
- **Kartenzahl**: ~60 Karten
@ -105,18 +120,21 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## 🎨 Kunst & Kultur
### Berühmte Gemälde und ihre Maler
- **Zielgruppe**: Kunstinteressierte, Studenten
- **Inhalt**: Meisterwerke, Künstler, Epochen
- **Kartenzahl**: ~75 Karten
- **Besonderheiten**: Hochauflösende Kunstwerke
### Klassische Musik: Komponisten
- **Zielgruppe**: Musikliebhaber, Studenten
- **Inhalt**: Biografien, wichtige Werke, Epochen
- **Kartenzahl**: ~50 Karten
- **Besonderheiten**: Audio-Snippets der Werke
### Architektur-Stile
- **Zielgruppe**: Architekturstudenten, Interessierte
- **Inhalt**: Gotik, Renaissance, Bauhaus, etc.
- **Kartenzahl**: ~40 Karten
@ -125,24 +143,28 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## 💻 Technologie
### HTML & CSS Grundlagen
- **Zielgruppe**: Webentwicklung-Anfänger
- **Inhalt**: Tags, Properties, Selektoren
- **Kartenzahl**: ~85 Karten
- **Besonderheiten**: Code-Beispiele
### JavaScript: Array-Methoden
- **Zielgruppe**: Programmier-Anfänger
- **Inhalt**: map, filter, reduce, forEach, etc.
- **Kartenzahl**: ~25 Karten
- **Besonderheiten**: Praktische Beispiele
### Git-Befehle
- **Zielgruppe**: Entwickler, Studenten
- **Inhalt**: Häufige Git-Commands und ihre Funktionen
- **Kartenzahl**: ~35 Karten
- **Besonderheiten**: Terminal-Syntax
### Cybersecurity Basics
- **Zielgruppe**: IT-Interessierte, Berufstätige
- **Inhalt**: Begriffe, Bedrohungen, Schutzmaßnahmen
- **Kartenzahl**: ~55 Karten
@ -151,18 +173,21 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## 🏥 Medizin & Gesundheit
### Erste Hilfe
- **Zielgruppe**: Allgemeine Bevölkerung
- **Inhalt**: Notfallmaßnahmen, Telefonnummern, Handlungsschritte
- **Kartenzahl**: ~40 Karten
- **Besonderheiten**: Lebensrettende Informationen
### Medikamente: Häufige Wirkstoffe
- **Zielgruppe**: Medizinstudenten, Pflegekräfte
- **Inhalt**: Wirkstoffnamen, Anwendungsgebiete, Nebenwirkungen
- **Kartenzahl**: ~60 Karten
- **Besonderheiten**: Systematische Kategorisierung
### Anatomie: Herz-Kreislauf-System
- **Zielgruppe**: Medizinstudenten
- **Inhalt**: Herzfunktion, Blutkreislauf, Erkrankungen
- **Kartenzahl**: ~80 Karten
@ -171,12 +196,14 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## 🌱 Nachhaltigkeit & Umwelt
### Klimawandel: Grundlagen
- **Zielgruppe**: Schüler, umweltbewusste Menschen
- **Inhalt**: Ursachen, Folgen, Lösungsansätze
- **Kartenzahl**: ~50 Karten
- **Besonderheiten**: Aktuelle Daten und Grafiken
### Recycling & Müllvermeidung
- **Zielgruppe**: Haushalte, umweltbewusste Menschen
- **Inhalt**: Mülltrennung, Wiederverwertung, Tipps
- **Kartenzahl**: ~30 Karten
@ -185,18 +212,21 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## 🍳 Alltag & Freizeit
### Kochen: Grundtechniken
- **Zielgruppe**: Kochanfänger
- **Inhalt**: Schneidetechniken, Garmethoden, Begriffe
- **Kartenzahl**: ~45 Karten
- **Besonderheiten**: Step-by-step Anleitungen
### Gartenpflege: Saisonkalender
- **Zielgruppe**: Hobbygärtner
- **Inhalt**: Pflanzzeiten, Pflegetipps, häufige Pflanzen
- **Kartenzahl**: ~65 Karten
- **Besonderheiten**: Monatsbasierte Organisation
### Finanzbildung: Grundlagen
- **Zielgruppe**: Junge Erwachsene, Finanzinteressierte
- **Inhalt**: Sparen, Investieren, Versicherungen
- **Kartenzahl**: ~50 Karten
@ -205,30 +235,36 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
## Implementierungsrichtlinien
### Qualitätsstandards
1. **Faktische Richtigkeit**: Alle Informationen müssen korrekt und aktuell sein
2. **Quellenangaben**: Bei wissenschaftlichen/historischen Inhalten Quellen angeben
3. **Altersangemessenheit**: Inhalte an die Zielgruppe anpassen
4. **Rechtliche Aspekte**: Urheberrechte bei Bildern und Inhalten beachten
### Technische Anforderungen
1. **Einheitliche Formatierung**: Konsistente Kartenerstellung
2. **Multimedia**: Bilder in hoher Qualität, Audio in guter Qualität
3. **Kategorisierung**: Richtige Zuordnung zu Kategorien
4. **Tags**: Aussagekräftige Tags für bessere Auffindbarkeit
### Priorisierung
**Phase 1** (Sofort umsetzen):
- Deutsch für Anfänger
- HTML & CSS Grundlagen
- Erste Hilfe
- Grundrechenarten für Kinder
**Phase 2** (Nach Nutzerfeedback):
- English Business Vocabulary
- Deutsche Geschichte: 20. Jahrhundert
- Grundlagen der Chemie
**Phase 3** (Erweiterung):
- Spezialisierte Decks basierend auf Nutzeranfragen
## Wartung und Updates
@ -239,4 +275,4 @@ Diese Liste enthält Vorschläge für hochwertige öffentliche Decks, die der Ma
---
*Letzte Aktualisierung: 2025-09-24*
_Letzte Aktualisierung: 2025-09-24_

View file

@ -3,49 +3,51 @@ import { ExpoConfig, ConfigContext } from 'expo/config';
const IS_DEV = process.env.EAS_BUILD_PROFILE === 'development';
export default ({ config }: ConfigContext): ExpoConfig => {
// Base plugins for all builds
const basePlugins = [
'expo-router',
'expo-font',
'expo-web-browser',
[
'expo-image-picker',
{
photosPermission: 'Diese App benötigt Zugriff auf deine Fotos, um Bilder für Lernkarten auszuwählen.',
cameraPermission: 'Diese App benötigt Zugriff auf die Kamera, um Fotos für Lernkarten aufzunehmen.',
},
],
[
'expo-build-properties',
{
ios: {
deploymentTarget: '16.0',
newArchEnabled: true,
},
android: {
targetSdkVersion: 36,
compileSdkVersion: 36,
newArchEnabled: true,
},
},
],
];
// Base plugins for all builds
const basePlugins = [
'expo-router',
'expo-font',
'expo-web-browser',
[
'expo-image-picker',
{
photosPermission:
'Diese App benötigt Zugriff auf deine Fotos, um Bilder für Lernkarten auszuwählen.',
cameraPermission:
'Diese App benötigt Zugriff auf die Kamera, um Fotos für Lernkarten aufzunehmen.',
},
],
[
'expo-build-properties',
{
ios: {
deploymentTarget: '16.0',
newArchEnabled: true,
},
android: {
targetSdkVersion: 36,
compileSdkVersion: 36,
newArchEnabled: true,
},
},
],
];
// Only add dev-launcher in development builds
const plugins = IS_DEV
? [
...basePlugins,
[
'expo-dev-launcher',
{
launchMode: 'most-recent',
},
],
]
: basePlugins;
// Only add dev-launcher in development builds
const plugins = IS_DEV
? [
...basePlugins,
[
'expo-dev-launcher',
{
launchMode: 'most-recent',
},
],
]
: basePlugins;
return {
...config,
plugins,
};
};
return {
...config,
plugins,
};
};

View file

@ -1,74 +1,72 @@
{
"expo": {
"name": "manadeck",
"slug": "manadeck",
"version": "1.0.0",
"scheme": "manadeck",
"runtimeVersion": {
"policy": "appVersion"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.tilljs.manadeck",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSMicrophoneUsageDescription": "Diese App benötigt Zugriff auf das Mikrofon, um Sprachaufnahmen für die Lernkarten-Erstellung zu ermöglichen.",
"NSCameraUsageDescription": "Diese App benötigt Zugriff auf die Kamera, um Fotos für die Lernkarten-Erstellung aufzunehmen.",
"NSPhotoLibraryUsageDescription": "Diese App benötigt Zugriff auf die Fotobibliothek, um Bilder für die Lernkarten-Erstellung auszuwählen.",
"NSSpeechRecognitionUsageDescription": "Diese App verwendet Spracherkennung, um Sprachaufnahmen in Text umzuwandeln.",
"NSDocumentsFolderUsageDescription": "Diese App benötigt Zugriff auf Dokumente, um Lernmaterialien zu importieren."
},
"appleTeamId": "QP3GLU8PH3",
"config": {
"googleSignIn": {
"reservedClientId": "com.googleusercontent.apps.111768794939-cgen6eklloo7k8vppcaq01o8r8nd7anb"
}
},
"usesAppleSignIn": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.tilljs.manadeck",
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.CAMERA",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE"
]
},
"extra": {
"router": {},
"eas": {
"projectId": "6cb9cf81-a4d5-4c72-b57d-1be3da8eba35"
}
},
"plugins": [
"expo-router",
"@react-native-google-signin/google-signin",
"expo-apple-authentication"
]
}
"expo": {
"name": "manadeck",
"slug": "manadeck",
"version": "1.0.0",
"scheme": "manadeck",
"runtimeVersion": {
"policy": "appVersion"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.tilljs.manadeck",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSMicrophoneUsageDescription": "Diese App benötigt Zugriff auf das Mikrofon, um Sprachaufnahmen für die Lernkarten-Erstellung zu ermöglichen.",
"NSCameraUsageDescription": "Diese App benötigt Zugriff auf die Kamera, um Fotos für die Lernkarten-Erstellung aufzunehmen.",
"NSPhotoLibraryUsageDescription": "Diese App benötigt Zugriff auf die Fotobibliothek, um Bilder für die Lernkarten-Erstellung auszuwählen.",
"NSSpeechRecognitionUsageDescription": "Diese App verwendet Spracherkennung, um Sprachaufnahmen in Text umzuwandeln.",
"NSDocumentsFolderUsageDescription": "Diese App benötigt Zugriff auf Dokumente, um Lernmaterialien zu importieren."
},
"appleTeamId": "QP3GLU8PH3",
"config": {
"googleSignIn": {
"reservedClientId": "com.googleusercontent.apps.111768794939-cgen6eklloo7k8vppcaq01o8r8nd7anb"
}
},
"usesAppleSignIn": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.tilljs.manadeck",
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.CAMERA",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE"
]
},
"extra": {
"router": {},
"eas": {
"projectId": "6cb9cf81-a4d5-4c72-b57d-1be3da8eba35"
}
},
"plugins": [
"expo-router",
"@react-native-google-signin/google-signin",
"expo-apple-authentication"
]
}
}

View file

@ -1,15 +1,16 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#f3f4f6' },
}}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
<Stack.Screen name="forgot-password" />
</Stack>
);
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#f3f4f6' },
}}
>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
<Stack.Screen name="forgot-password" />
</Stack>
);
}

View file

@ -1,12 +1,5 @@
import React, { useState } from 'react';
import {
View,
ScrollView,
KeyboardAvoidingView,
Platform,
Pressable,
Alert,
} from 'react-native';
import { View, ScrollView, KeyboardAvoidingView, Platform, Pressable, Alert } from 'react-native';
import { Link, router } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Text } from '~/components/ui/Text';
@ -19,117 +12,160 @@ import { useThemeColors } from '~/utils/themeUtils';
import { spacing } from '~/utils/spacing';
export default function ForgotPasswordScreen() {
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<{ email?: string }>({});
const [isSubmitted, setIsSubmitted] = useState(false);
const colors = useThemeColors();
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<{ email?: string }>({});
const [isSubmitted, setIsSubmitted] = useState(false);
const colors = useThemeColors();
const { resetPassword, isLoading, clearError } = useAuthStore();
const { resetPassword, isLoading, clearError } = useAuthStore();
const validateForm = () => {
const newErrors: { email?: string } = {};
const validateForm = () => {
const newErrors: { email?: string } = {};
if (!email) {
newErrors.email = 'E-Mail ist erforderlich';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Ungültige E-Mail-Adresse';
}
if (!email) {
newErrors.email = 'E-Mail ist erforderlich';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Ungültige E-Mail-Adresse';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleResetPassword = async () => {
if (!validateForm()) return;
const handleResetPassword = async () => {
if (!validateForm()) return;
try {
clearError();
await resetPassword(email);
setIsSubmitted(true);
} catch (err: any) {
Alert.alert('Zurücksetzen fehlgeschlagen', err.message || 'Ein Fehler ist aufgetreten');
}
};
try {
clearError();
await resetPassword(email);
setIsSubmitted(true);
} catch (err: any) {
Alert.alert('Zurücksetzen fehlgeschlagen', err.message || 'Ein Fehler ist aufgetreten');
}
};
if (isSubmitted) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<View style={{ flex: 1, justifyContent: 'center', paddingHorizontal: 24 }}>
<Card padding="lg" variant="elevated" style={{ alignItems: 'center' }}>
<View style={{ marginBottom: spacing.lg, height: 80, width: 80, alignItems: 'center', justifyContent: 'center', borderRadius: 40, backgroundColor: '#10b98120' }}>
<Icon name="checkmark-circle" size={48} color="#10b981" library="Ionicons" />
</View>
if (isSubmitted) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<View style={{ flex: 1, justifyContent: 'center', paddingHorizontal: 24 }}>
<Card padding="lg" variant="elevated" style={{ alignItems: 'center' }}>
<View
style={{
marginBottom: spacing.lg,
height: 80,
width: 80,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 40,
backgroundColor: '#10b98120',
}}
>
<Icon name="checkmark-circle" size={48} color="#10b981" library="Ionicons" />
</View>
<Text style={{ marginBottom: 8, textAlign: 'center', fontSize: 24, fontWeight: 'bold', color: colors.foreground }}>
E-Mail gesendet!
</Text>
<Text
style={{
marginBottom: 8,
textAlign: 'center',
fontSize: 24,
fontWeight: 'bold',
color: colors.foreground,
}}
>
E-Mail gesendet!
</Text>
<Text style={{ marginBottom: spacing.xl, textAlign: 'center', color: colors.mutedForeground }}>
Wir haben dir eine E-Mail mit Anweisungen zum Zurücksetzen deines Passworts an {email}{' '}
gesendet.
</Text>
<Text
style={{
marginBottom: spacing.xl,
textAlign: 'center',
color: colors.mutedForeground,
}}
>
Wir haben dir eine E-Mail mit Anweisungen zum Zurücksetzen deines Passworts an {email}{' '}
gesendet.
</Text>
<Button onPress={() => router.replace('/(auth)/login')} fullWidth size="lg">
Zurück zur Anmeldung
</Button>
</Card>
</View>
</SafeAreaView>
);
}
<Button onPress={() => router.replace('/(auth)/login')} fullWidth size="lg">
Zurück zur Anmeldung
</Button>
</Card>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}>
<ScrollView contentContainerStyle={{ flexGrow: 1 }} keyboardShouldPersistTaps="handled">
<View style={{ flex: 1, paddingHorizontal: 24, paddingVertical: 32 }}>
<Pressable onPress={() => router.back()} style={{ marginBottom: spacing.xl, flexDirection: 'row', alignItems: 'center' }}>
<Icon name="arrow-back" size={24} color={colors.foreground} library="Ionicons" />
<Text style={{ marginLeft: 8, color: colors.foreground }}>Zurück</Text>
</Pressable>
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView contentContainerStyle={{ flexGrow: 1 }} keyboardShouldPersistTaps="handled">
<View style={{ flex: 1, paddingHorizontal: 24, paddingVertical: 32 }}>
<Pressable
onPress={() => router.back()}
style={{ marginBottom: spacing.xl, flexDirection: 'row', alignItems: 'center' }}
>
<Icon name="arrow-back" size={24} color={colors.foreground} library="Ionicons" />
<Text style={{ marginLeft: 8, color: colors.foreground }}>Zurück</Text>
</Pressable>
<View style={{ flex: 1, justifyContent: 'center' }}>
<View style={{ marginBottom: spacing.xxl }}>
<Text style={{ marginBottom: spacing.sm, textAlign: 'center', fontSize: 32, fontWeight: 'bold', color: colors.foreground }}>
Passwort vergessen?
</Text>
<Text style={{ textAlign: 'center', color: colors.mutedForeground }}>
Kein Problem! Gib deine E-Mail-Adresse ein und wir senden dir Anweisungen zum
Zurücksetzen.
</Text>
</View>
<View style={{ flex: 1, justifyContent: 'center' }}>
<View style={{ marginBottom: spacing.xxl }}>
<Text
style={{
marginBottom: spacing.sm,
textAlign: 'center',
fontSize: 32,
fontWeight: 'bold',
color: colors.foreground,
}}
>
Passwort vergessen?
</Text>
<Text style={{ textAlign: 'center', color: colors.mutedForeground }}>
Kein Problem! Gib deine E-Mail-Adresse ein und wir senden dir Anweisungen zum
Zurücksetzen.
</Text>
</View>
<Card padding="lg" variant="elevated">
<Input
label="E-Mail-Adresse"
type="email"
placeholder="deine@email.de"
value={email}
onChangeText={setEmail}
error={errors.email}
leftIcon="mail-outline"
autoComplete="email"
/>
<Card padding="lg" variant="elevated">
<Input
label="E-Mail-Adresse"
type="email"
placeholder="deine@email.de"
value={email}
onChangeText={setEmail}
error={errors.email}
leftIcon="mail-outline"
autoComplete="email"
/>
<Button onPress={handleResetPassword} loading={isLoading} fullWidth size="lg">
Zurücksetzen-Link senden
</Button>
<Button onPress={handleResetPassword} loading={isLoading} fullWidth size="lg">
Zurücksetzen-Link senden
</Button>
<View style={{ marginTop: spacing.xl, flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.mutedForeground }}>Erinnerst du dich wieder? </Text>
<Link href="/(auth)/login" asChild>
<Pressable>
<Text style={{ fontWeight: '600', color: colors.primary }}>Anmelden</Text>
</Pressable>
</Link>
</View>
</Card>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
<View
style={{
marginTop: spacing.xl,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: colors.mutedForeground }}>Erinnerst du dich wieder? </Text>
<Link href="/(auth)/login" asChild>
<Pressable>
<Text style={{ fontWeight: '600', color: colors.primary }}>Anmelden</Text>
</Pressable>
</Link>
</View>
</Card>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View file

@ -1,12 +1,5 @@
import React, { useState } from 'react';
import {
View,
ScrollView,
KeyboardAvoidingView,
Platform,
Pressable,
Alert,
} from 'react-native';
import { View, ScrollView, KeyboardAvoidingView, Platform, Pressable, Alert } from 'react-native';
import { Link, router } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Text } from '~/components/ui/Text';
@ -20,147 +13,183 @@ import { AppleSignInButton } from '../../components/auth/AppleSignInButton';
import { spacing } from '~/utils/spacing';
export default function LoginScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const colors = useThemeColors();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const colors = useThemeColors();
const { signIn, signInWithGoogle, signInWithApple, isLoading, error, clearError } = useAuthStore();
const { signIn, signInWithGoogle, signInWithApple, isLoading, error, clearError } =
useAuthStore();
const validateForm = () => {
const newErrors: { email?: string; password?: string } = {};
const validateForm = () => {
const newErrors: { email?: string; password?: string } = {};
if (!email) {
newErrors.email = 'E-Mail ist erforderlich';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Ungültige E-Mail-Adresse';
}
if (!email) {
newErrors.email = 'E-Mail ist erforderlich';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Ungültige E-Mail-Adresse';
}
if (!password) {
newErrors.password = 'Passwort ist erforderlich';
} else if (password.length < 6) {
newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein';
}
if (!password) {
newErrors.password = 'Passwort ist erforderlich';
} else if (password.length < 6) {
newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleLogin = async () => {
if (!validateForm()) return;
const handleLogin = async () => {
if (!validateForm()) return;
try {
clearError();
await signIn(email, password);
router.replace('/(tabs)');
} catch (err: any) {
Alert.alert('Login fehlgeschlagen', err.message || 'Ein Fehler ist aufgetreten');
}
};
try {
clearError();
await signIn(email, password);
router.replace('/(tabs)');
} catch (err: any) {
Alert.alert('Login fehlgeschlagen', err.message || 'Ein Fehler ist aufgetreten');
}
};
const handleGoogleSignIn = async (idToken: string) => {
try {
clearError();
await signInWithGoogle(idToken);
router.replace('/(tabs)');
} catch (err: any) {
// Error is already handled in GoogleSignInButton
throw err;
}
};
const handleGoogleSignIn = async (idToken: string) => {
try {
clearError();
await signInWithGoogle(idToken);
router.replace('/(tabs)');
} catch (err: any) {
// Error is already handled in GoogleSignInButton
throw err;
}
};
const handleAppleSignIn = async (identityToken: string) => {
try {
clearError();
await signInWithApple(identityToken);
router.replace('/(tabs)');
} catch (err: any) {
// Error is already handled in AppleSignInButton
throw err;
}
};
const handleAppleSignIn = async (identityToken: string) => {
try {
clearError();
await signInWithApple(identityToken);
router.replace('/(tabs)');
} catch (err: any) {
// Error is already handled in AppleSignInButton
throw err;
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}>
<ScrollView contentContainerStyle={{ flexGrow: 1 }} keyboardShouldPersistTaps="handled">
<View style={{ flex: 1, justifyContent: 'center', paddingHorizontal: 24, paddingVertical: 32 }}>
<View style={{ marginBottom: spacing.xxl }}>
<Text style={{ marginBottom: spacing.sm, textAlign: 'center', fontSize: 32, fontWeight: 'bold', color: colors.foreground }}>
Willkommen zurück
</Text>
<Text style={{ textAlign: 'center', color: colors.mutedForeground }}>Melde dich an, um fortzufahren</Text>
</View>
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView contentContainerStyle={{ flexGrow: 1 }} keyboardShouldPersistTaps="handled">
<View
style={{
flex: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 32,
}}
>
<View style={{ marginBottom: spacing.xxl }}>
<Text
style={{
marginBottom: spacing.sm,
textAlign: 'center',
fontSize: 32,
fontWeight: 'bold',
color: colors.foreground,
}}
>
Willkommen zurück
</Text>
<Text style={{ textAlign: 'center', color: colors.mutedForeground }}>
Melde dich an, um fortzufahren
</Text>
</View>
<Card padding="lg" variant="elevated">
<Input
label="E-Mail"
type="email"
placeholder="deine@email.de"
value={email}
onChangeText={setEmail}
error={errors.email}
leftIcon="mail-outline"
autoComplete="email"
/>
<Card padding="lg" variant="elevated">
<Input
label="E-Mail"
type="email"
placeholder="deine@email.de"
value={email}
onChangeText={setEmail}
error={errors.email}
leftIcon="mail-outline"
autoComplete="email"
/>
<Input
label="Passwort"
type="password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
error={errors.password}
leftIcon="lock-closed-outline"
/>
<Input
label="Passwort"
type="password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
error={errors.password}
leftIcon="lock-closed-outline"
/>
<Pressable style={{ marginBottom: spacing.lg }}>
<Link href="/(auth)/forgot-password" asChild>
<Text style={{ textAlign: 'right', fontSize: 14, color: colors.primary }}>Passwort vergessen?</Text>
</Link>
</Pressable>
<Pressable style={{ marginBottom: spacing.lg }}>
<Link href="/(auth)/forgot-password" asChild>
<Text style={{ textAlign: 'right', fontSize: 14, color: colors.primary }}>
Passwort vergessen?
</Text>
</Link>
</Pressable>
<Button onPress={handleLogin} loading={isLoading} fullWidth size="lg">
Anmelden
</Button>
<Button onPress={handleLogin} loading={isLoading} fullWidth size="lg">
Anmelden
</Button>
{/* Social Sign-In Divider */}
<View style={{ marginVertical: spacing.xl, flexDirection: 'row', alignItems: 'center' }}>
<View style={{ flex: 1, height: 1, backgroundColor: colors.border }} />
<Text style={{ marginHorizontal: spacing.lg, color: colors.mutedForeground, fontSize: 14 }}>
oder
</Text>
<View style={{ flex: 1, height: 1, backgroundColor: colors.border }} />
</View>
{/* Social Sign-In Divider */}
<View
style={{ marginVertical: spacing.xl, flexDirection: 'row', alignItems: 'center' }}
>
<View style={{ flex: 1, height: 1, backgroundColor: colors.border }} />
<Text
style={{
marginHorizontal: spacing.lg,
color: colors.mutedForeground,
fontSize: 14,
}}
>
oder
</Text>
<View style={{ flex: 1, height: 1, backgroundColor: colors.border }} />
</View>
{/* Social Sign-In Buttons */}
<View style={{ gap: spacing.content.small }}>
<GoogleSignInButton
onSignIn={handleGoogleSignIn}
onSignInSuccess={() => console.log('Google sign-in successful')}
onSignInError={(error) => console.error('Google sign-in error:', error)}
/>
<AppleSignInButton
onSignIn={handleAppleSignIn}
onSignInSuccess={() => console.log('Apple sign-in successful')}
onSignInError={(error) => console.error('Apple sign-in error:', error)}
/>
</View>
{/* Social Sign-In Buttons */}
<View style={{ gap: spacing.content.small }}>
<GoogleSignInButton
onSignIn={handleGoogleSignIn}
onSignInSuccess={() => console.log('Google sign-in successful')}
onSignInError={(error) => console.error('Google sign-in error:', error)}
/>
<AppleSignInButton
onSignIn={handleAppleSignIn}
onSignInSuccess={() => console.log('Apple sign-in successful')}
onSignInError={(error) => console.error('Apple sign-in error:', error)}
/>
</View>
<View style={{ marginTop: spacing.xl, flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.mutedForeground }}>Noch kein Konto? </Text>
<Link href="/(auth)/register" asChild>
<Pressable>
<Text style={{ fontWeight: '600', color: colors.primary }}>Registrieren</Text>
</Pressable>
</Link>
</View>
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
<View
style={{
marginTop: spacing.xl,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: colors.mutedForeground }}>Noch kein Konto? </Text>
<Link href="/(auth)/register" asChild>
<Pressable>
<Text style={{ fontWeight: '600', color: colors.primary }}>Registrieren</Text>
</Pressable>
</Link>
</View>
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View file

@ -1,12 +1,5 @@
import React, { useState } from 'react';
import {
View,
ScrollView,
KeyboardAvoidingView,
Platform,
Pressable,
Alert,
} from 'react-native';
import { View, ScrollView, KeyboardAvoidingView, Platform, Pressable, Alert } from 'react-native';
import { Link, router } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Text } from '~/components/ui/Text';
@ -18,139 +11,164 @@ import { useThemeColors } from '~/utils/themeUtils';
import { spacing } from '~/utils/spacing';
export default function RegisterScreen() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [errors, setErrors] = useState<{
username?: string;
email?: string;
password?: string;
confirmPassword?: string;
}>({});
const colors = useThemeColors();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [errors, setErrors] = useState<{
username?: string;
email?: string;
password?: string;
confirmPassword?: string;
}>({});
const colors = useThemeColors();
const { signUp, isLoading, clearError } = useAuthStore();
const { signUp, isLoading, clearError } = useAuthStore();
const validateForm = () => {
const newErrors: typeof errors = {};
const validateForm = () => {
const newErrors: typeof errors = {};
if (!username) {
newErrors.username = 'Benutzername ist erforderlich';
} else if (username.length < 3) {
newErrors.username = 'Benutzername muss mindestens 3 Zeichen lang sein';
}
if (!username) {
newErrors.username = 'Benutzername ist erforderlich';
} else if (username.length < 3) {
newErrors.username = 'Benutzername muss mindestens 3 Zeichen lang sein';
}
if (!email) {
newErrors.email = 'E-Mail ist erforderlich';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Ungültige E-Mail-Adresse';
}
if (!email) {
newErrors.email = 'E-Mail ist erforderlich';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Ungültige E-Mail-Adresse';
}
if (!password) {
newErrors.password = 'Passwort ist erforderlich';
} else if (password.length < 6) {
newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein';
}
if (!password) {
newErrors.password = 'Passwort ist erforderlich';
} else if (password.length < 6) {
newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein';
}
if (!confirmPassword) {
newErrors.confirmPassword = 'Passwort-Bestätigung ist erforderlich';
} else if (password !== confirmPassword) {
newErrors.confirmPassword = 'Passwörter stimmen nicht überein';
}
if (!confirmPassword) {
newErrors.confirmPassword = 'Passwort-Bestätigung ist erforderlich';
} else if (password !== confirmPassword) {
newErrors.confirmPassword = 'Passwörter stimmen nicht überein';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleRegister = async () => {
if (!validateForm()) return;
const handleRegister = async () => {
if (!validateForm()) return;
try {
clearError();
await signUp(email, password, username);
Alert.alert(
'Registrierung erfolgreich!',
'Bitte überprüfe deine E-Mail, um dein Konto zu bestätigen.',
[{ text: 'OK', onPress: () => router.replace('/(auth)/login') }]
);
} catch (err: any) {
Alert.alert('Registrierung fehlgeschlagen', err.message || 'Ein Fehler ist aufgetreten');
}
};
try {
clearError();
await signUp(email, password, username);
Alert.alert(
'Registrierung erfolgreich!',
'Bitte überprüfe deine E-Mail, um dein Konto zu bestätigen.',
[{ text: 'OK', onPress: () => router.replace('/(auth)/login') }]
);
} catch (err: any) {
Alert.alert('Registrierung fehlgeschlagen', err.message || 'Ein Fehler ist aufgetreten');
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}>
<ScrollView contentContainerStyle={{ flexGrow: 1 }} keyboardShouldPersistTaps="handled">
<View style={{ flex: 1, justifyContent: 'center', paddingHorizontal: 24, paddingVertical: 32 }}>
<View style={{ marginBottom: spacing.xxl }}>
<Text style={{ marginBottom: spacing.sm, textAlign: 'center', fontSize: 32, fontWeight: 'bold', color: colors.foreground }}>
Konto erstellen
</Text>
<Text style={{ textAlign: 'center', color: colors.mutedForeground }}>Registriere dich, um loszulegen</Text>
</View>
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView contentContainerStyle={{ flexGrow: 1 }} keyboardShouldPersistTaps="handled">
<View
style={{
flex: 1,
justifyContent: 'center',
paddingHorizontal: 24,
paddingVertical: 32,
}}
>
<View style={{ marginBottom: spacing.xxl }}>
<Text
style={{
marginBottom: spacing.sm,
textAlign: 'center',
fontSize: 32,
fontWeight: 'bold',
color: colors.foreground,
}}
>
Konto erstellen
</Text>
<Text style={{ textAlign: 'center', color: colors.mutedForeground }}>
Registriere dich, um loszulegen
</Text>
</View>
<Card padding="lg" variant="elevated">
<Input
label="Benutzername"
placeholder="deinbenutzername"
value={username}
onChangeText={setUsername}
error={errors.username}
leftIcon="person-outline"
autoComplete="username"
/>
<Card padding="lg" variant="elevated">
<Input
label="Benutzername"
placeholder="deinbenutzername"
value={username}
onChangeText={setUsername}
error={errors.username}
leftIcon="person-outline"
autoComplete="username"
/>
<Input
label="E-Mail"
type="email"
placeholder="deine@email.de"
value={email}
onChangeText={setEmail}
error={errors.email}
leftIcon="mail-outline"
autoComplete="email"
/>
<Input
label="E-Mail"
type="email"
placeholder="deine@email.de"
value={email}
onChangeText={setEmail}
error={errors.email}
leftIcon="mail-outline"
autoComplete="email"
/>
<Input
label="Passwort"
type="password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
error={errors.password}
leftIcon="lock-closed-outline"
/>
<Input
label="Passwort"
type="password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
error={errors.password}
leftIcon="lock-closed-outline"
/>
<Input
label="Passwort bestätigen"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChangeText={setConfirmPassword}
error={errors.confirmPassword}
leftIcon="lock-closed-outline"
/>
<Input
label="Passwort bestätigen"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChangeText={setConfirmPassword}
error={errors.confirmPassword}
leftIcon="lock-closed-outline"
/>
<Button onPress={handleRegister} loading={isLoading} fullWidth size="lg">
Registrieren
</Button>
<Button onPress={handleRegister} loading={isLoading} fullWidth size="lg">
Registrieren
</Button>
<View style={{ marginTop: spacing.xl, flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.mutedForeground }}>Bereits ein Konto? </Text>
<Link href="/(auth)/login" asChild>
<Pressable>
<Text style={{ fontWeight: '600', color: colors.primary }}>Anmelden</Text>
</Pressable>
</Link>
</View>
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
<View
style={{
marginTop: spacing.xl,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: colors.mutedForeground }}>Bereits ein Konto? </Text>
<Link href="/(auth)/login" asChild>
<Pressable>
<Text style={{ fontWeight: '600', color: colors.primary }}>Anmelden</Text>
</Pressable>
</Link>
</View>
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View file

@ -2,33 +2,33 @@ import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
import { Platform } from 'react-native';
export default function TabLayout() {
return (
<NativeTabs minimizeBehavior="automatic">
<NativeTabs.Trigger name="decks">
<Label>Decks</Label>
<Icon
sf={{ default: 'square.stack.3d.up', selected: 'square.stack.3d.up.fill' }}
drawable="ic_albums"
/>
</NativeTabs.Trigger>
return (
<NativeTabs minimizeBehavior="automatic">
<NativeTabs.Trigger name="decks">
<Label>Decks</Label>
<Icon
sf={{ default: 'square.stack.3d.up', selected: 'square.stack.3d.up.fill' }}
drawable="ic_albums"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<Label>Entdecken</Label>
<Icon sf={{ default: 'safari', selected: 'safari.fill' }} drawable="ic_explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="explore">
<Label>Entdecken</Label>
<Icon sf={{ default: 'safari', selected: 'safari.fill' }} drawable="ic_explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="progress">
<Label>Fortschritt</Label>
<Icon
sf={{ default: 'chart.line.uptrend.xyaxis', selected: 'chart.line.uptrend.xyaxis' }}
drawable="ic_trending_up"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="progress">
<Label>Fortschritt</Label>
<Icon
sf={{ default: 'chart.line.uptrend.xyaxis', selected: 'chart.line.uptrend.xyaxis' }}
drawable="ic_trending_up"
/>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="profile">
<Label>Profil</Label>
<Icon sf={{ default: 'person', selected: 'person.fill' }} drawable="ic_person" />
</NativeTabs.Trigger>
</NativeTabs>
);
<NativeTabs.Trigger name="profile">
<Label>Profil</Label>
<Icon sf={{ default: 'person', selected: 'person.fill' }} drawable="ic_person" />
</NativeTabs.Trigger>
</NativeTabs>
);
}

View file

@ -1,12 +1,13 @@
import { Stack } from 'expo-router';
export default function DecksLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
}}>
<Stack.Screen name="index" />
</Stack>
);
return (
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="index" />
</Stack>
);
}

View file

@ -14,311 +14,346 @@ import { PageHeader } from '~/components/ui/PageHeader';
import { spacing } from '~/utils/spacing';
// Conditional imports for Expo UI
let Host: any = null, Picker: any = null;
let Host: any = null,
Picker: any = null;
if (Platform.OS === 'ios') {
try {
const SwiftUI = require('@expo/ui/swift-ui');
Host = SwiftUI?.Host;
Picker = SwiftUI?.Picker;
// Verify the components are actually available and have the expected shape
if (!Host || !Picker || !Picker.Item) {
Host = null;
Picker = null;
}
} catch (e) {
// Fallback if @expo/ui is not available
}
try {
const SwiftUI = require('@expo/ui/swift-ui');
Host = SwiftUI?.Host;
Picker = SwiftUI?.Picker;
// Verify the components are actually available and have the expected shape
if (!Host || !Picker || !Picker.Item) {
Host = null;
Picker = null;
}
} catch (e) {
// Fallback if @expo/ui is not available
}
} else if (Platform.OS === 'android') {
try {
const JetpackCompose = require('@expo/ui/jetpack-compose');
Host = JetpackCompose?.Host;
Picker = JetpackCompose?.Picker;
// Verify the components are actually available and have the expected shape
if (!Host || !Picker || !Picker.Item) {
Host = null;
Picker = null;
}
} catch (e) {
// Fallback if @expo/ui is not available
}
try {
const JetpackCompose = require('@expo/ui/jetpack-compose');
Host = JetpackCompose?.Host;
Picker = JetpackCompose?.Picker;
// Verify the components are actually available and have the expected shape
if (!Host || !Picker || !Picker.Item) {
Host = null;
Picker = null;
}
} catch (e) {
// Fallback if @expo/ui is not available
}
}
export default function DecksScreen() {
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<string>('all');
const colors = useThemeColors();
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<string>('all');
const colors = useThemeColors();
const { decks, fetchDecks, isLoading } = useDeckStore();
const { decks, fetchDecks, isLoading } = useDeckStore();
useEffect(() => {
fetchDecks();
}, []);
useEffect(() => {
fetchDecks();
}, []);
const onRefresh = React.useCallback(async () => {
setRefreshing(true);
await fetchDecks();
setRefreshing(false);
}, [fetchDecks]);
const onRefresh = React.useCallback(async () => {
setRefreshing(true);
await fetchDecks();
setRefreshing(false);
}, [fetchDecks]);
const filteredDecks = React.useMemo(() => {
if (filter === 'favorites') {
return decks.filter((deck) => deck.metadata?.is_favorite);
}
return decks;
}, [decks, filter]);
const filteredDecks = React.useMemo(() => {
if (filter === 'favorites') {
return decks.filter((deck) => deck.metadata?.is_favorite);
}
return decks;
}, [decks, filter]);
const favoritesCount = React.useMemo(
() => decks.filter((deck) => deck.metadata?.is_favorite).length,
[decks]
);
const favoritesCount = React.useMemo(
() => decks.filter((deck) => deck.metadata?.is_favorite).length,
[decks]
);
const filterOptions: FilterOption[] = [
{ id: 'all', label: 'Alle', count: decks.length },
{ id: 'favorites', label: 'Favoriten', icon: 'heart', count: favoritesCount },
];
const filterOptions: FilterOption[] = [
{ id: 'all', label: 'Alle', count: decks.length },
{ id: 'favorites', label: 'Favoriten', icon: 'heart', count: favoritesCount },
];
// Native iOS SwiftUI Picker component
const NativeIOSPicker = () => {
if (Host && Picker && Picker.Item) {
try {
return (
<View style={{
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: 'transparent',
}}>
<Host style={{ height: 36 }} matchContents>
<Picker
variant="segmented"
value={filter}
onValueChange={(value: string) => setFilter(value)}
>
<Picker.Item value="all">{`Alle (${decks.length})`}</Picker.Item>
<Picker.Item value="favorites">{`Favoriten (${favoritesCount})`}</Picker.Item>
</Picker>
</Host>
</View>
);
} catch (error) {
return <IOSSegmentedControl />;
}
}
// Fallback to custom component if Expo UI is not available
return <IOSSegmentedControl />;
};
// Native iOS SwiftUI Picker component
const NativeIOSPicker = () => {
if (Host && Picker && Picker.Item) {
try {
return (
<View
style={{
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: 'transparent',
}}
>
<Host style={{ height: 36 }} matchContents>
<Picker
variant="segmented"
value={filter}
onValueChange={(value: string) => setFilter(value)}
>
<Picker.Item value="all">{`Alle (${decks.length})`}</Picker.Item>
<Picker.Item value="favorites">{`Favoriten (${favoritesCount})`}</Picker.Item>
</Picker>
</Host>
</View>
);
} catch (error) {
return <IOSSegmentedControl />;
}
}
// Fallback to custom component if Expo UI is not available
return <IOSSegmentedControl />;
};
// Native Android Jetpack Compose component
const NativeAndroidPicker = () => {
if (Host && Picker && Picker.Item) {
try {
return (
<View style={{
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: 'transparent',
}}>
<Host style={{ height: 48 }}>
<Picker
variant="chips"
value={filter}
onValueChange={(value: string) => setFilter(value)}
multiple={false}
>
<Picker.Item value="all">{`Alle (${decks.length})`}</Picker.Item>
<Picker.Item value="favorites">{`Favoriten (${favoritesCount})`}</Picker.Item>
</Picker>
</Host>
</View>
);
} catch (error) {
return <FilterBar options={filterOptions} activeFilter={filter} onFilterChange={setFilter} />;
}
}
// Fallback to FilterBar for Android if Expo UI is not available
return <FilterBar options={filterOptions} activeFilter={filter} onFilterChange={setFilter} />;
};
// Native Android Jetpack Compose component
const NativeAndroidPicker = () => {
if (Host && Picker && Picker.Item) {
try {
return (
<View
style={{
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: 'transparent',
}}
>
<Host style={{ height: 48 }}>
<Picker
variant="chips"
value={filter}
onValueChange={(value: string) => setFilter(value)}
multiple={false}
>
<Picker.Item value="all">{`Alle (${decks.length})`}</Picker.Item>
<Picker.Item value="favorites">{`Favoriten (${favoritesCount})`}</Picker.Item>
</Picker>
</Host>
</View>
);
} catch (error) {
return (
<FilterBar options={filterOptions} activeFilter={filter} onFilterChange={setFilter} />
);
}
}
// Fallback to FilterBar for Android if Expo UI is not available
return <FilterBar options={filterOptions} activeFilter={filter} onFilterChange={setFilter} />;
};
// Custom iOS-style segmented control as fallback
const IOSSegmentedControl = () => (
<View style={{
alignItems: 'center',
paddingVertical: 12,
}}>
<View style={{
flexDirection: 'row',
backgroundColor: colors.muted,
borderRadius: 9,
padding: 2,
height: 32,
minWidth: 280,
maxWidth: 360,
}}>
<Pressable
onPress={() => setFilter('all')}
style={{
flex: 1,
backgroundColor: filter === 'all' ? colors.background : 'transparent',
borderRadius: 7,
alignItems: 'center',
justifyContent: 'center',
shadowColor: filter === 'all' ? '#000' : 'transparent',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: filter === 'all' ? 0.15 : 0,
shadowRadius: 2,
elevation: filter === 'all' ? 2 : 0,
}}>
<Text style={{
fontSize: 13,
fontWeight: '600',
color: filter === 'all' ? colors.foreground : colors.mutedForeground
}}>
Alle {decks.length > 0 ? `(${decks.length})` : ''}
</Text>
</Pressable>
<Pressable
onPress={() => setFilter('favorites')}
style={{
flex: 1,
backgroundColor: filter === 'favorites' ? colors.background : 'transparent',
borderRadius: 7,
alignItems: 'center',
justifyContent: 'center',
shadowColor: filter === 'favorites' ? '#000' : 'transparent',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: filter === 'favorites' ? 0.15 : 0,
shadowRadius: 2,
elevation: filter === 'favorites' ? 2 : 0,
}}>
<Text style={{
fontSize: 13,
fontWeight: '600',
color: filter === 'favorites' ? colors.foreground : colors.mutedForeground
}}>
Favoriten {favoritesCount > 0 ? `(${favoritesCount})` : ''}
</Text>
</Pressable>
</View>
</View>
);
// Custom iOS-style segmented control as fallback
const IOSSegmentedControl = () => (
<View
style={{
alignItems: 'center',
paddingVertical: 12,
}}
>
<View
style={{
flexDirection: 'row',
backgroundColor: colors.muted,
borderRadius: 9,
padding: 2,
height: 32,
minWidth: 280,
maxWidth: 360,
}}
>
<Pressable
onPress={() => setFilter('all')}
style={{
flex: 1,
backgroundColor: filter === 'all' ? colors.background : 'transparent',
borderRadius: 7,
alignItems: 'center',
justifyContent: 'center',
shadowColor: filter === 'all' ? '#000' : 'transparent',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: filter === 'all' ? 0.15 : 0,
shadowRadius: 2,
elevation: filter === 'all' ? 2 : 0,
}}
>
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: filter === 'all' ? colors.foreground : colors.mutedForeground,
}}
>
Alle {decks.length > 0 ? `(${decks.length})` : ''}
</Text>
</Pressable>
<Pressable
onPress={() => setFilter('favorites')}
style={{
flex: 1,
backgroundColor: filter === 'favorites' ? colors.background : 'transparent',
borderRadius: 7,
alignItems: 'center',
justifyContent: 'center',
shadowColor: filter === 'favorites' ? '#000' : 'transparent',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: filter === 'favorites' ? 0.15 : 0,
shadowRadius: 2,
elevation: filter === 'favorites' ? 2 : 0,
}}
>
<Text
style={{
fontSize: 13,
fontWeight: '600',
color: filter === 'favorites' ? colors.foreground : colors.mutedForeground,
}}
>
Favoriten {favoritesCount > 0 ? `(${favoritesCount})` : ''}
</Text>
</Pressable>
</View>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
{filteredDecks.length === 0 ? (
<ScrollView
style={{ flex: 1 }}
refreshControl={
<RefreshControl refreshing={refreshing || isLoading} onRefresh={onRefresh} />
}>
<PageHeader title="Meine Decks" />
<View style={{ paddingHorizontal: spacing.container.horizontal, paddingTop: spacing.container.top }}>
<Card padding="lg" variant="elevated">
<View style={{ alignItems: 'center', paddingVertical: spacing.content.title }}>
<View
style={{
marginBottom: spacing.content.title,
height: 96,
width: 96,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 48,
backgroundColor: colors.muted,
}}>
<Icon name="albums-outline" size={48} color={colors.mutedForeground} library="Ionicons" />
</View>
<Text
style={{
marginBottom: spacing.content.small,
fontSize: 20,
fontWeight: '600',
color: colors.foreground,
}}>
{filter === 'favorites' ? 'Keine Favoriten' : 'Keine Decks vorhanden'}
</Text>
<Text
style={{
marginBottom: spacing.content.title,
paddingHorizontal: spacing.lg,
textAlign: 'center',
color: colors.mutedForeground,
}}>
{filter === 'favorites'
? 'Markiere Decks als Favoriten, um sie hier zu sehen'
: 'Erstelle dein erstes Deck oder entdecke öffentliche Decks von anderen Nutzern'}
</Text>
{filter === 'all' && (
<View style={{ gap: spacing.content.small, width: '100%', maxWidth: 300 }}>
<Button
onPress={() => router.push('/deck/create')}
variant="primary"
leftIcon={
<Icon
name="add-circle-outline"
size={20}
color="white"
library="Ionicons"
/>
}>
Deck erstellen
</Button>
<Button
onPress={() => router.push('/(tabs)/explore')}
variant="outline"
leftIcon={
<Icon
name="compass-outline"
size={20}
color="currentColor"
library="Ionicons"
/>
}>
Decks entdecken
</Button>
</View>
)}
</View>
</Card>
</View>
</ScrollView>
) : (
<FlatList
ListHeaderComponent={
<>
<PageHeader title="Meine Decks" withHorizontalPadding={false} />
<View style={{ height: spacing.container.top }} />
</>
}
data={filteredDecks}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<DeckCard
deck={item}
onPress={() => router.push(`/deck/${item.id}`)}
showProgress={true}
/>
)}
contentContainerStyle={{
paddingHorizontal: spacing.container.horizontal,
paddingBottom: spacing.lg,
}}
refreshControl={
<RefreshControl refreshing={refreshing || isLoading} onRefresh={onRefresh} />
}
ItemSeparatorComponent={() => <View style={{ height: spacing.lg }} />}
/>
)}
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
{filteredDecks.length === 0 ? (
<ScrollView
style={{ flex: 1 }}
refreshControl={
<RefreshControl refreshing={refreshing || isLoading} onRefresh={onRefresh} />
}
>
<PageHeader title="Meine Decks" />
<View
style={{
paddingHorizontal: spacing.container.horizontal,
paddingTop: spacing.container.top,
}}
>
<Card padding="lg" variant="elevated">
<View style={{ alignItems: 'center', paddingVertical: spacing.content.title }}>
<View
style={{
marginBottom: spacing.content.title,
height: 96,
width: 96,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 48,
backgroundColor: colors.muted,
}}
>
<Icon
name="albums-outline"
size={48}
color={colors.mutedForeground}
library="Ionicons"
/>
</View>
<Text
style={{
marginBottom: spacing.content.small,
fontSize: 20,
fontWeight: '600',
color: colors.foreground,
}}
>
{filter === 'favorites' ? 'Keine Favoriten' : 'Keine Decks vorhanden'}
</Text>
<Text
style={{
marginBottom: spacing.content.title,
paddingHorizontal: spacing.lg,
textAlign: 'center',
color: colors.mutedForeground,
}}
>
{filter === 'favorites'
? 'Markiere Decks als Favoriten, um sie hier zu sehen'
: 'Erstelle dein erstes Deck oder entdecke öffentliche Decks von anderen Nutzern'}
</Text>
{filter === 'all' && (
<View style={{ gap: spacing.content.small, width: '100%', maxWidth: 300 }}>
<Button
onPress={() => router.push('/deck/create')}
variant="primary"
leftIcon={
<Icon
name="add-circle-outline"
size={20}
color="white"
library="Ionicons"
/>
}
>
Deck erstellen
</Button>
<Button
onPress={() => router.push('/(tabs)/explore')}
variant="outline"
leftIcon={
<Icon
name="compass-outline"
size={20}
color="currentColor"
library="Ionicons"
/>
}
>
Decks entdecken
</Button>
</View>
)}
</View>
</Card>
</View>
</ScrollView>
) : (
<FlatList
ListHeaderComponent={
<>
<PageHeader title="Meine Decks" withHorizontalPadding={false} />
<View style={{ height: spacing.container.top }} />
</>
}
data={filteredDecks}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<DeckCard
deck={item}
onPress={() => router.push(`/deck/${item.id}`)}
showProgress={true}
/>
)}
contentContainerStyle={{
paddingHorizontal: spacing.container.horizontal,
paddingBottom: spacing.lg,
}}
refreshControl={
<RefreshControl refreshing={refreshing || isLoading} onRefresh={onRefresh} />
}
ItemSeparatorComponent={() => <View style={{ height: spacing.lg }} />}
/>
)}
{/* Floating Action Button */}
<View style={{
position: 'absolute',
bottom: Platform.OS === 'ios' ? 100 : 80,
right: 20,
zIndex: 1000,
}}>
<FloatingActionButton
icon={filter === 'all' ? 'heart' : 'albums'}
onPress={() => setFilter(filter === 'all' ? 'favorites' : 'all')}
/>
</View>
</View>
);
{/* Floating Action Button */}
<View
style={{
position: 'absolute',
bottom: Platform.OS === 'ios' ? 100 : 80,
right: 20,
zIndex: 1000,
}}
>
<FloatingActionButton
icon={filter === 'all' ? 'heart' : 'albums'}
onPress={() => setFilter(filter === 'all' ? 'favorites' : 'all')}
/>
</View>
</View>
);
}

View file

@ -1,12 +1,13 @@
import { Stack } from 'expo-router';
export default function ExploreLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
}}>
<Stack.Screen name="index" />
</Stack>
);
return (
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="index" />
</Stack>
);
}

View file

@ -8,96 +8,135 @@ import { PageHeader } from '~/components/ui/PageHeader';
import { spacing } from '~/utils/spacing';
export default function ExploreScreen() {
const [refreshing, setRefreshing] = useState(false);
const colors = useThemeColors(); // This triggers theme reactivity
const [refreshing, setRefreshing] = useState(false);
const colors = useThemeColors(); // This triggers theme reactivity
const onRefresh = React.useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setRefreshing(false);
}, 2000);
}, []);
const onRefresh = React.useCallback(() => {
setRefreshing(true);
setTimeout(() => {
setRefreshing(false);
}, 2000);
}, []);
const categories = [
{ name: 'Sprachen', icon: 'language-outline', color: colors.primary },
{ name: 'Wissenschaft', icon: 'flask-outline', color: colors.secondary },
{ name: 'Mathematik', icon: 'calculator-outline', color: colors.accent },
{ name: 'Geschichte', icon: 'time-outline', color: colors.primary },
{ name: 'Kunst', icon: 'color-palette-outline', color: colors.secondary },
{ name: 'Technologie', icon: 'hardware-chip-outline', color: colors.accent },
];
const categories = [
{ name: 'Sprachen', icon: 'language-outline', color: colors.primary },
{ name: 'Wissenschaft', icon: 'flask-outline', color: colors.secondary },
{ name: 'Mathematik', icon: 'calculator-outline', color: colors.accent },
{ name: 'Geschichte', icon: 'time-outline', color: colors.primary },
{ name: 'Kunst', icon: 'color-palette-outline', color: colors.secondary },
{ name: 'Technologie', icon: 'hardware-chip-outline', color: colors.accent },
];
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView
style={{ flex: 1 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<PageHeader title="Entdecken" />
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView
style={{ flex: 1 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
<PageHeader title="Entdecken" />
{/* Content Wrapper */}
<View style={{ paddingHorizontal: spacing.container.horizontal, paddingTop: spacing.container.top }}>
{/* Kategorien Card */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<Text style={{ marginBottom: spacing.content.title, fontSize: 18, fontWeight: '600', color: colors.foreground }}>Kategorien</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginHorizontal: -4 }}>
{categories.map((category, index) => (
<View key={index} style={{ width: '33.333%', paddingHorizontal: 4, marginBottom: 8 }}>
<Card
padding="none"
variant="elevated"
onPress={() => {}}
style={{
aspectRatio: 1,
padding: 12,
}}>
<View style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center'
}}>
<View
style={{
height: 40,
width: 40,
backgroundColor: category.color,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
marginBottom: 8
}}>
<Icon name={category.icon} size={20} color="white" library="Ionicons" />
</View>
<Text
numberOfLines={1}
style={{
textAlign: 'center',
fontSize: 12,
fontWeight: '500',
color: colors.foreground,
width: '100%'
}}>
{category.name}
</Text>
</View>
</Card>
</View>
))}
</View>
</Card>
{/* Content Wrapper */}
<View
style={{
paddingHorizontal: spacing.container.horizontal,
paddingTop: spacing.container.top,
}}
>
{/* Kategorien Card */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<Text
style={{
marginBottom: spacing.content.title,
fontSize: 18,
fontWeight: '600',
color: colors.foreground,
}}
>
Kategorien
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginHorizontal: -4 }}>
{categories.map((category, index) => (
<View
key={index}
style={{ width: '33.333%', paddingHorizontal: 4, marginBottom: 8 }}
>
<Card
padding="none"
variant="elevated"
onPress={() => {}}
style={{
aspectRatio: 1,
padding: 12,
}}
>
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
}}
>
<View
style={{
height: 40,
width: 40,
backgroundColor: category.color,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
marginBottom: 8,
}}
>
<Icon name={category.icon} size={20} color="white" library="Ionicons" />
</View>
<Text
numberOfLines={1}
style={{
textAlign: 'center',
fontSize: 12,
fontWeight: '500',
color: colors.foreground,
width: '100%',
}}
>
{category.name}
</Text>
</View>
</Card>
</View>
))}
</View>
</Card>
{/* Beliebte Decks Card */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<Text style={{ marginBottom: spacing.content.title, fontSize: 18, fontWeight: '600', color: colors.foreground }}>Beliebte Decks</Text>
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
<Icon name="compass-outline" size={48} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginTop: 8, color: colors.mutedForeground }}>Noch keine öffentlichen Decks</Text>
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>
Sei der Erste, der ein Deck teilt!
</Text>
</View>
</Card>
</View>
</ScrollView>
</View>
);
{/* Beliebte Decks Card */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<Text
style={{
marginBottom: spacing.content.title,
fontSize: 18,
fontWeight: '600',
color: colors.foreground,
}}
>
Beliebte Decks
</Text>
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
<Icon
name="compass-outline"
size={48}
color={colors.mutedForeground}
library="Ionicons"
/>
<Text style={{ marginTop: 8, color: colors.mutedForeground }}>
Noch keine öffentlichen Decks
</Text>
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>
Sei der Erste, der ein Deck teilt!
</Text>
</View>
</Card>
</View>
</ScrollView>
</View>
);
}

View file

@ -1,5 +1,5 @@
import { Redirect } from 'expo-router';
export default function TabsIndex() {
return <Redirect href="/(tabs)/decks" />;
return <Redirect href="/(tabs)/decks" />;
}

View file

@ -1,12 +1,13 @@
import { Stack } from 'expo-router';
export default function ProfileLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
}}>
<Stack.Screen name="index" />
</Stack>
);
return (
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="index" />
</Stack>
);
}

View file

@ -17,322 +17,342 @@ import { PageHeader } from '~/components/ui/PageHeader';
import { spacing } from '~/utils/spacing';
export default function ProfileScreen() {
const { user, signOut } = useAuthStore();
const { decks, fetchDecks } = useDeckStore();
const { credits, loading: creditsLoading, error: creditsError, refetch } = useCredits();
const colors = useThemeColors(); // This triggers theme reactivity
const { user, signOut } = useAuthStore();
const { decks, fetchDecks } = useDeckStore();
const { credits, loading: creditsLoading, error: creditsError, refetch } = useCredits();
const colors = useThemeColors(); // This triggers theme reactivity
// Settings state
const [notifications, setNotifications] = useState({
pushNotifications: true,
emailNotifications: false,
studyReminders: true,
weeklyProgress: true,
});
// Settings state
const [notifications, setNotifications] = useState({
pushNotifications: true,
emailNotifications: false,
studyReminders: true,
weeklyProgress: true,
});
const [privacy, setPrivacy] = useState({
profilePublic: false,
shareProgress: true,
allowAnalytics: true,
});
const [privacy, setPrivacy] = useState({
profilePublic: false,
shareProgress: true,
allowAnalytics: true,
});
const [study, setStudy] = useState({
autoPlay: false,
soundEffects: true,
vibration: true,
darkModeForStudy: false,
});
const [study, setStudy] = useState({
autoPlay: false,
soundEffects: true,
vibration: true,
darkModeForStudy: false,
});
useEffect(() => {
fetchDecks();
}, []);
useEffect(() => {
fetchDecks();
}, []);
const handleSignOut = async () => {
// Use native confirm for web compatibility
if (Platform.OS === 'web') {
if (window.confirm('Möchtest du dich wirklich abmelden?')) {
try {
await signOut();
router.replace('/(auth)/login');
} catch (error) {
console.error('Sign out error:', error);
window.alert('Abmeldung fehlgeschlagen');
}
}
} else {
Alert.alert('Abmelden', 'Möchtest du dich wirklich abmelden?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Abmelden',
style: 'destructive',
onPress: async () => {
try {
await signOut();
router.replace('/(auth)/login');
} catch (error) {
console.error('Sign out error:', error);
Alert.alert('Fehler', 'Abmeldung fehlgeschlagen');
}
},
},
]);
}
};
const handleSignOut = async () => {
// Use native confirm for web compatibility
if (Platform.OS === 'web') {
if (window.confirm('Möchtest du dich wirklich abmelden?')) {
try {
await signOut();
router.replace('/(auth)/login');
} catch (error) {
console.error('Sign out error:', error);
window.alert('Abmeldung fehlgeschlagen');
}
}
} else {
Alert.alert('Abmelden', 'Möchtest du dich wirklich abmelden?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Abmelden',
style: 'destructive',
onPress: async () => {
try {
await signOut();
router.replace('/(auth)/login');
} catch (error) {
console.error('Sign out error:', error);
Alert.alert('Fehler', 'Abmeldung fehlgeschlagen');
}
},
},
]);
}
};
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingBottom: spacing.tabBar.clearance,
}}
>
<PageHeader title="Profil" />
<View
style={{
paddingHorizontal: spacing.container.horizontal,
paddingTop: spacing.container.top,
}}
>
{/* User Profile Section */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<View style={{ alignItems: 'center' }}>
<View
style={{
marginBottom: spacing.content.small,
height: 80,
width: 80,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 40,
backgroundColor: colors.primary,
}}
>
<Text style={{ fontSize: 24, fontWeight: 'bold', color: 'white' }}>
{user?.email?.[0].toUpperCase() || 'U'}
</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{user?.email?.split('@')[0] || 'Benutzer'}
</Text>
<Text style={{ marginTop: spacing.xs, fontSize: 14, color: colors.mutedForeground }}>
{user?.email}
</Text>
</View>
</Card>
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingBottom: spacing.tabBar.clearance,
}}>
<PageHeader title="Profil" />
<View style={{ paddingHorizontal: spacing.container.horizontal, paddingTop: spacing.container.top }}>
{/* User Profile Section */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<View style={{ alignItems: 'center' }}>
<View style={{
marginBottom: spacing.content.small,
height: 80,
width: 80,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 40,
backgroundColor: colors.primary
}}>
<Text style={{ fontSize: 24, fontWeight: 'bold', color: 'white' }}>
{user?.email?.[0].toUpperCase() || 'U'}
</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{user?.email?.split('@')[0] || 'Benutzer'}
</Text>
<Text style={{ marginTop: spacing.xs, fontSize: 14, color: colors.mutedForeground }}>{user?.email}</Text>
</View>
</Card>
{/* Mana Balance Section */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<View style={{ alignItems: 'center' }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.content.small,
}}
>
<Icon
name="flash"
library="Ionicons"
size={24}
color={colors.primary}
style={{ marginRight: spacing.xs }}
/>
<Text style={{ fontSize: 16, fontWeight: '600', color: colors.foreground }}>
Mana Balance
</Text>
</View>
{/* Mana Balance Section */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<View style={{ alignItems: 'center' }}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.content.small
}}>
<Icon
name="flash"
library="Ionicons"
size={24}
color={colors.primary}
style={{ marginRight: spacing.xs }}
/>
<Text style={{ fontSize: 16, fontWeight: '600', color: colors.foreground }}>
Mana Balance
</Text>
</View>
{creditsLoading ? (
<ActivityIndicator
size="small"
color={colors.primary}
style={{ marginVertical: spacing.content.small }}
/>
) : creditsError ? (
<View style={{ alignItems: 'center' }}>
<Text
style={{
fontSize: 14,
color: colors.destructive,
marginBottom: spacing.content.small,
}}
>
Fehler beim Laden
</Text>
<Pressable
onPress={refetch}
style={{
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.primary,
borderRadius: 8,
}}
>
<Text style={{ color: 'white', fontSize: 14, fontWeight: '600' }}>
Erneut versuchen
</Text>
</Pressable>
</View>
) : (
<>
<Text
style={{
fontSize: 32,
fontWeight: 'bold',
color: colors.primary,
marginVertical: spacing.content.small,
}}
>
{credits ?? 0}
</Text>
<Text style={{ fontSize: 14, color: colors.mutedForeground }}>
Credits verfügbar
</Text>
</>
)}
</View>
</Card>
{creditsLoading ? (
<ActivityIndicator size="small" color={colors.primary} style={{ marginVertical: spacing.content.small }} />
) : creditsError ? (
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 14, color: colors.destructive, marginBottom: spacing.content.small }}>
Fehler beim Laden
</Text>
<Pressable
onPress={refetch}
style={{
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: colors.primary,
borderRadius: 8
}}
>
<Text style={{ color: 'white', fontSize: 14, fontWeight: '600' }}>
Erneut versuchen
</Text>
</Pressable>
</View>
) : (
<>
<Text style={{
fontSize: 32,
fontWeight: 'bold',
color: colors.primary,
marginVertical: spacing.content.small
}}>
{credits ?? 0}
</Text>
<Text style={{ fontSize: 14, color: colors.mutedForeground }}>
Credits verfügbar
</Text>
</>
)}
</View>
</Card>
{/* Benachrichtigungen */}
<SettingsSection title="Benachrichtigungen">
<SettingsItem
title="Push-Benachrichtigungen"
description="Erhalte Benachrichtigungen auf deinem Gerät"
rightElement={
<Switch
value={notifications.pushNotifications}
onValueChange={(value) =>
setNotifications((prev) => ({ ...prev, pushNotifications: value }))
}
/>
}
/>
<SettingsItem
title="E-Mail Benachrichtigungen"
description="Erhalte Updates per E-Mail"
rightElement={
<Switch
value={notifications.emailNotifications}
onValueChange={(value) =>
setNotifications((prev) => ({ ...prev, emailNotifications: value }))
}
/>
}
/>
<SettingsItem
title="Lern-Erinnerungen"
description="Tägliche Erinnerungen zum Lernen"
rightElement={
<Switch
value={notifications.studyReminders}
onValueChange={(value) =>
setNotifications((prev) => ({ ...prev, studyReminders: value }))
}
/>
}
/>
<SettingsItem
title="Wochen-Fortschritt"
description="Wöchentliche Zusammenfassung deines Fortschritts"
rightElement={
<Switch
value={notifications.weeklyProgress}
onValueChange={(value) =>
setNotifications((prev) => ({ ...prev, weeklyProgress: value }))
}
/>
}
isLast
/>
</SettingsSection>
{/* Benachrichtigungen */}
<SettingsSection title="Benachrichtigungen">
<SettingsItem
title="Push-Benachrichtigungen"
description="Erhalte Benachrichtigungen auf deinem Gerät"
rightElement={
<Switch
value={notifications.pushNotifications}
onValueChange={(value) =>
setNotifications((prev) => ({ ...prev, pushNotifications: value }))
}
/>
}
/>
<SettingsItem
title="E-Mail Benachrichtigungen"
description="Erhalte Updates per E-Mail"
rightElement={
<Switch
value={notifications.emailNotifications}
onValueChange={(value) =>
setNotifications((prev) => ({ ...prev, emailNotifications: value }))
}
/>
}
/>
<SettingsItem
title="Lern-Erinnerungen"
description="Tägliche Erinnerungen zum Lernen"
rightElement={
<Switch
value={notifications.studyReminders}
onValueChange={(value) =>
setNotifications((prev) => ({ ...prev, studyReminders: value }))
}
/>
}
/>
<SettingsItem
title="Wochen-Fortschritt"
description="Wöchentliche Zusammenfassung deines Fortschritts"
rightElement={
<Switch
value={notifications.weeklyProgress}
onValueChange={(value) =>
setNotifications((prev) => ({ ...prev, weeklyProgress: value }))
}
/>
}
isLast
/>
</SettingsSection>
{/* Lern-Einstellungen */}
<SettingsSection title="Lern-Einstellungen">
<SettingsItem
title="Auto-Play Audio"
description="Automatische Audiowiedergabe bei Audio-Karten"
rightElement={
<Switch
value={study.autoPlay}
onValueChange={(value) => setStudy((prev) => ({ ...prev, autoPlay: value }))}
/>
}
/>
<SettingsItem
title="Sound-Effekte"
description="Akustisches Feedback beim Lernen"
rightElement={
<Switch
value={study.soundEffects}
onValueChange={(value) => setStudy((prev) => ({ ...prev, soundEffects: value }))}
/>
}
/>
<SettingsItem
title="Vibration"
description="Haptisches Feedback aktivieren"
rightElement={
<Switch
value={study.vibration}
onValueChange={(value) => setStudy((prev) => ({ ...prev, vibration: value }))}
/>
}
isLast
/>
</SettingsSection>
{/* Lern-Einstellungen */}
<SettingsSection title="Lern-Einstellungen">
<SettingsItem
title="Auto-Play Audio"
description="Automatische Audiowiedergabe bei Audio-Karten"
rightElement={
<Switch
value={study.autoPlay}
onValueChange={(value) => setStudy((prev) => ({ ...prev, autoPlay: value }))}
/>
}
/>
<SettingsItem
title="Sound-Effekte"
description="Akustisches Feedback beim Lernen"
rightElement={
<Switch
value={study.soundEffects}
onValueChange={(value) => setStudy((prev) => ({ ...prev, soundEffects: value }))}
/>
}
/>
<SettingsItem
title="Vibration"
description="Haptisches Feedback aktivieren"
rightElement={
<Switch
value={study.vibration}
onValueChange={(value) => setStudy((prev) => ({ ...prev, vibration: value }))}
/>
}
isLast
/>
</SettingsSection>
{/* Design & Darstellung */}
<SettingsSection title="Design & Darstellung" noPadding>
<ThemeSwitcher />
</SettingsSection>
{/* Design & Darstellung */}
<SettingsSection title="Design & Darstellung" noPadding>
<ThemeSwitcher />
</SettingsSection>
{/* Datenschutz */}
<SettingsSection title="Datenschutz & Sicherheit">
<SettingsItem
title="Öffentliches Profil"
description="Dein Profil für andere sichtbar machen"
rightElement={
<Switch
value={privacy.profilePublic}
onValueChange={(value) =>
setPrivacy((prev) => ({ ...prev, profilePublic: value }))
}
/>
}
/>
<SettingsItem
title="Fortschritt teilen"
description="Erlaube anderen, deinen Lernfortschritt zu sehen"
rightElement={
<Switch
value={privacy.shareProgress}
onValueChange={(value) =>
setPrivacy((prev) => ({ ...prev, shareProgress: value }))
}
/>
}
/>
<SettingsItem
title="Analyse-Daten"
description="Hilf uns, die App zu verbessern"
rightElement={
<Switch
value={privacy.allowAnalytics}
onValueChange={(value) =>
setPrivacy((prev) => ({ ...prev, allowAnalytics: value }))
}
/>
}
isLast
/>
</SettingsSection>
{/* Datenschutz */}
<SettingsSection title="Datenschutz & Sicherheit">
<SettingsItem
title="Öffentliches Profil"
description="Dein Profil für andere sichtbar machen"
rightElement={
<Switch
value={privacy.profilePublic}
onValueChange={(value) =>
setPrivacy((prev) => ({ ...prev, profilePublic: value }))
}
/>
}
/>
<SettingsItem
title="Fortschritt teilen"
description="Erlaube anderen, deinen Lernfortschritt zu sehen"
rightElement={
<Switch
value={privacy.shareProgress}
onValueChange={(value) =>
setPrivacy((prev) => ({ ...prev, shareProgress: value }))
}
/>
}
/>
<SettingsItem
title="Analyse-Daten"
description="Hilf uns, die App zu verbessern"
rightElement={
<Switch
value={privacy.allowAnalytics}
onValueChange={(value) =>
setPrivacy((prev) => ({ ...prev, allowAnalytics: value }))
}
/>
}
isLast
/>
</SettingsSection>
{/* Weitere Optionen */}
<SettingsSection title="Weitere Optionen">
<SettingsItem title="Profil bearbeiten" onPress={() => {}} />
<SettingsItem
title="Export/Import"
description="Deine Daten sichern oder übertragen"
onPress={() => {}}
/>
<SettingsItem title="Hilfe & Support" onPress={() => {}} />
<SettingsItem title="Über die App" onPress={() => {}} isLast />
</SettingsSection>
{/* Weitere Optionen */}
<SettingsSection title="Weitere Optionen">
<SettingsItem title="Profil bearbeiten" onPress={() => {}} />
<SettingsItem
title="Export/Import"
description="Deine Daten sichern oder übertragen"
onPress={() => {}}
/>
<SettingsItem title="Hilfe & Support" onPress={() => {}} />
<SettingsItem
title="Über die App"
onPress={() => {}}
isLast
/>
</SettingsSection>
{/* Abmelden */}
<View style={{ marginTop: spacing.lg }}>
<Button
onPress={handleSignOut}
variant="danger"
fullWidth
leftIcon={<Icon name="log-out-outline" size={20} color="white" library="Ionicons" />}>
Abmelden
</Button>
</View>
</View>
</ScrollView>
</View>
);
{/* Abmelden */}
<View style={{ marginTop: spacing.lg }}>
<Button
onPress={handleSignOut}
variant="danger"
fullWidth
leftIcon={<Icon name="log-out-outline" size={20} color="white" library="Ionicons" />}
>
Abmelden
</Button>
</View>
</View>
</ScrollView>
</View>
);
}

View file

@ -1,12 +1,13 @@
import { Stack } from 'expo-router';
export default function ProgressLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
}}>
<Stack.Screen name="index" />
</Stack>
);
return (
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="index" />
</Stack>
);
}

View file

@ -14,255 +14,388 @@ import { PageHeader } from '~/components/ui/PageHeader';
import { spacing } from '~/utils/spacing';
export default function ProgressScreen() {
const { user } = useAuthStore();
const colors = useThemeColors(); // This triggers theme reactivity
const {
dailyProgress,
streakInfo,
deckProgress,
statistics,
selectedPeriod,
isLoading,
fetchDailyProgress,
fetchStreakInfo,
fetchDeckProgress,
fetchStatistics,
setSelectedPeriod,
} = useProgressStore();
const { user } = useAuthStore();
const colors = useThemeColors(); // This triggers theme reactivity
const {
dailyProgress,
streakInfo,
deckProgress,
statistics,
selectedPeriod,
isLoading,
fetchDailyProgress,
fetchStreakInfo,
fetchDeckProgress,
fetchStatistics,
setSelectedPeriod,
} = useProgressStore();
const [activeChart, setActiveChart] = useState<'accuracy' | 'cards' | 'time'>('cards');
const [activeChart, setActiveChart] = useState<'accuracy' | 'cards' | 'time'>('cards');
useEffect(() => {
if (user) {
loadProgressData();
}
}, [user, selectedPeriod]);
useEffect(() => {
if (user) {
loadProgressData();
}
}, [user, selectedPeriod]);
const loadProgressData = async () => {
if (!user) return;
const loadProgressData = async () => {
if (!user) return;
const endDate = new Date();
const startDate = new Date();
const endDate = new Date();
const startDate = new Date();
// Get data for selected period + extra for heatmap
switch (selectedPeriod) {
case 'week':
startDate.setDate(endDate.getDate() - 90); // 3 months for heatmap
break;
case 'month':
startDate.setDate(endDate.getDate() - 90);
break;
case 'year':
startDate.setFullYear(endDate.getFullYear() - 1);
break;
}
// Get data for selected period + extra for heatmap
switch (selectedPeriod) {
case 'week':
startDate.setDate(endDate.getDate() - 90); // 3 months for heatmap
break;
case 'month':
startDate.setDate(endDate.getDate() - 90);
break;
case 'year':
startDate.setFullYear(endDate.getFullYear() - 1);
break;
}
await Promise.all([
fetchDailyProgress(user.id, startDate, endDate),
fetchStreakInfo(user.id),
fetchDeckProgress(user.id),
fetchStatistics(user.id),
]);
};
await Promise.all([
fetchDailyProgress(user.id, startDate, endDate),
fetchStreakInfo(user.id),
fetchDeckProgress(user.id),
fetchStatistics(user.id),
]);
};
if (isLoading && dailyProgress.size === 0) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={{ marginTop: 8, color: colors.mutedForeground }}>Lade Fortschritt...</Text>
</View>
</View>
);
}
if (isLoading && dailyProgress.size === 0) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={{ marginTop: 8, color: colors.mutedForeground }}>Lade Fortschritt...</Text>
</View>
</View>
);
}
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView showsVerticalScrollIndicator={false}>
<PageHeader title="Fortschritt" />
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView showsVerticalScrollIndicator={false}>
<PageHeader title="Fortschritt" />
{/* Content Wrapper */}
<View style={{ paddingHorizontal: spacing.container.horizontal, paddingTop: spacing.container.top }}>
{/* Streak Section */}
<View style={{ marginBottom: spacing.section }}>
<StreakCard streakInfo={streakInfo} />
</View>
{/* Content Wrapper */}
<View
style={{
paddingHorizontal: spacing.container.horizontal,
paddingTop: spacing.container.top,
}}
>
{/* Streak Section */}
<View style={{ marginBottom: spacing.section }}>
<StreakCard streakInfo={streakInfo} />
</View>
{/* Heatmap Calendar */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<Text style={{ marginBottom: spacing.content.small, fontSize: 18, fontWeight: '600', color: colors.foreground }}>Aktivitätskalender</Text>
<HeatmapCalendar data={dailyProgress} />
<View style={{ marginTop: spacing.content.small, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.lg }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 12, width: 12, borderRadius: 2, backgroundColor: colors.muted }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>Keine</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 12, width: 12, borderRadius: 2, backgroundColor: '#bbf7d0' }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>Wenig</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 12, width: 12, borderRadius: 2, backgroundColor: '#4ade80' }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>Mittel</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 12, width: 12, borderRadius: 2, backgroundColor: '#16a34a' }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>Viel</Text>
</View>
</View>
</Card>
{/* Heatmap Calendar */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<Text
style={{
marginBottom: spacing.content.small,
fontSize: 18,
fontWeight: '600',
color: colors.foreground,
}}
>
Aktivitätskalender
</Text>
<HeatmapCalendar data={dailyProgress} />
<View
style={{
marginTop: spacing.content.small,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.lg,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ height: 12, width: 12, borderRadius: 2, backgroundColor: colors.muted }}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
Keine
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ height: 12, width: 12, borderRadius: 2, backgroundColor: '#bbf7d0' }}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
Wenig
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ height: 12, width: 12, borderRadius: 2, backgroundColor: '#4ade80' }}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
Mittel
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ height: 12, width: 12, borderRadius: 2, backgroundColor: '#16a34a' }}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
Viel
</Text>
</View>
</View>
</Card>
{/* Charts Section */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
{/* Period Selector */}
<View style={{ marginBottom: spacing.content.title, flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>Statistiken</Text>
<View style={{ flexDirection: 'row', borderRadius: 8, backgroundColor: colors.muted, padding: 4 }}>
{(['week', 'month', 'year'] as const).map((period) => (
<Pressable
key={period}
onPress={() => setSelectedPeriod(period)}
style={({ pressed }) => ({
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 4,
backgroundColor: selectedPeriod === period ? colors.surface : 'transparent',
opacity: pressed ? 0.7 : 1,
...(selectedPeriod === period && {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
}),
})}>
<Text
style={{
fontSize: 14,
fontWeight: selectedPeriod === period ? '500' : '400',
color: selectedPeriod === period ? colors.foreground : colors.mutedForeground,
}}>
{period === 'week' ? 'Woche' : period === 'month' ? 'Monat' : 'Jahr'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Charts Section */}
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
{/* Period Selector */}
<View
style={{
marginBottom: spacing.content.title,
flexDirection: 'row',
justifyContent: 'space-between',
}}
>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
Statistiken
</Text>
<View
style={{
flexDirection: 'row',
borderRadius: 8,
backgroundColor: colors.muted,
padding: 4,
}}
>
{(['week', 'month', 'year'] as const).map((period) => (
<Pressable
key={period}
onPress={() => setSelectedPeriod(period)}
style={({ pressed }) => ({
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 4,
backgroundColor: selectedPeriod === period ? colors.surface : 'transparent',
opacity: pressed ? 0.7 : 1,
...(selectedPeriod === period && {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
}),
})}
>
<Text
style={{
fontSize: 14,
fontWeight: selectedPeriod === period ? '500' : '400',
color:
selectedPeriod === period ? colors.foreground : colors.mutedForeground,
}}
>
{period === 'week' ? 'Woche' : period === 'month' ? 'Monat' : 'Jahr'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Chart Type Selector */}
<View style={{ marginBottom: spacing.content.title, flexDirection: 'row', gap: spacing.sm }}>
{[
{ key: 'cards', label: 'Karten', icon: 'card-outline' },
{ key: 'accuracy', label: 'Genauigkeit', icon: 'checkmark-circle-outline' },
{ key: 'time', label: 'Zeit', icon: 'time-outline' },
].map((type) => (
<Pressable
key={type.key}
onPress={() => setActiveChart(type.key as any)}
style={({ pressed }) => ({
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
borderWidth: 1,
padding: 8,
borderColor: activeChart === type.key ? colors.primary : colors.border,
backgroundColor: activeChart === type.key ? `${colors.primary}15` : colors.surface,
opacity: pressed ? 0.7 : 1,
})}>
<Icon
name={type.icon}
size={16}
color={activeChart === type.key ? colors.primary : colors.mutedForeground}
library="Ionicons"
/>
<Text
style={{
marginLeft: 4,
fontSize: 14,
fontWeight: activeChart === type.key ? '500' : '400',
color: activeChart === type.key ? colors.primary : colors.mutedForeground,
}}>
{type.label}
</Text>
</Pressable>
))}
</View>
{/* Chart Type Selector */}
<View
style={{
marginBottom: spacing.content.title,
flexDirection: 'row',
gap: spacing.sm,
}}
>
{[
{ key: 'cards', label: 'Karten', icon: 'card-outline' },
{ key: 'accuracy', label: 'Genauigkeit', icon: 'checkmark-circle-outline' },
{ key: 'time', label: 'Zeit', icon: 'time-outline' },
].map((type) => (
<Pressable
key={type.key}
onPress={() => setActiveChart(type.key as any)}
style={({ pressed }) => ({
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
borderWidth: 1,
padding: 8,
borderColor: activeChart === type.key ? colors.primary : colors.border,
backgroundColor:
activeChart === type.key ? `${colors.primary}15` : colors.surface,
opacity: pressed ? 0.7 : 1,
})}
>
<Icon
name={type.icon}
size={16}
color={activeChart === type.key ? colors.primary : colors.mutedForeground}
library="Ionicons"
/>
<Text
style={{
marginLeft: 4,
fontSize: 14,
fontWeight: activeChart === type.key ? '500' : '400',
color: activeChart === type.key ? colors.primary : colors.mutedForeground,
}}
>
{type.label}
</Text>
</Pressable>
))}
</View>
{/* Chart */}
<ProgressChart type={activeChart} period={selectedPeriod} />
</Card>
{/* Chart */}
<ProgressChart type={activeChart} period={selectedPeriod} />
</Card>
{/* Statistics Overview */}
{statistics && (
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<Text style={{ marginBottom: spacing.content.title, fontSize: 18, fontWeight: '600', color: colors.foreground }}>Gesamt-Statistiken</Text>
{/* Statistics Overview */}
{statistics && (
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.section }}>
<Text
style={{
marginBottom: spacing.content.title,
fontSize: 18,
fontWeight: '600',
color: colors.foreground,
}}
>
Gesamt-Statistiken
</Text>
<View style={{ gap: spacing.content.small }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon name="card-outline" size={20} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginLeft: 8, color: colors.mutedForeground }}>Karten gelernt</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{statistics.total_cards_studied}
</Text>
</View>
<View style={{ gap: spacing.content.small }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon
name="card-outline"
size={20}
color={colors.mutedForeground}
library="Ionicons"
/>
<Text style={{ marginLeft: 8, color: colors.mutedForeground }}>
Karten gelernt
</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{statistics.total_cards_studied}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon name="time-outline" size={20} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginLeft: 8, color: colors.mutedForeground }}>Lernzeit</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{Math.floor(statistics.total_study_time_minutes / 60)}h{' '}
{statistics.total_study_time_minutes % 60}min
</Text>
</View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon
name="time-outline"
size={20}
color={colors.mutedForeground}
library="Ionicons"
/>
<Text style={{ marginLeft: 8, color: colors.mutedForeground }}>Lernzeit</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{Math.floor(statistics.total_study_time_minutes / 60)}h{' '}
{statistics.total_study_time_minutes % 60}min
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon name="trending-up-outline" size={20} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginLeft: 8, color: colors.mutedForeground }}>
Durchschnittliche Genauigkeit
</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{statistics.average_accuracy}%
</Text>
</View>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon
name="trending-up-outline"
size={20}
color={colors.mutedForeground}
library="Ionicons"
/>
<Text style={{ marginLeft: 8, color: colors.mutedForeground }}>
Durchschnittliche Genauigkeit
</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{statistics.average_accuracy}%
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon name="sunny-outline" size={20} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginLeft: 8, color: colors.mutedForeground }}>Lieblings-Lernzeit</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{statistics.favorite_time_of_day}
</Text>
</View>
</View>
</Card>
)}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Icon
name="sunny-outline"
size={20}
color={colors.mutedForeground}
library="Ionicons"
/>
<Text style={{ marginLeft: 8, color: colors.mutedForeground }}>
Lieblings-Lernzeit
</Text>
</View>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{statistics.favorite_time_of_day}
</Text>
</View>
</View>
</Card>
)}
{/* Deck Progress */}
<View style={{ marginBottom: spacing.section }}>
<Text style={{ marginBottom: spacing.content.title, fontSize: 18, fontWeight: '600', color: colors.foreground }}>Deck-Fortschritt</Text>
{deckProgress.length > 0 ? (
<View style={{ gap: spacing.content.item }}>
{deckProgress.map((deck) => (
<DeckProgressCard key={deck.deck_id} progress={deck} />
))}
</View>
) : (
<Card padding="lg" variant="outlined">
<Text style={{ textAlign: 'center', color: colors.mutedForeground }}>Noch keine Decks vorhanden</Text>
</Card>
)}
</View>
</View>
</ScrollView>
</View>
);
{/* Deck Progress */}
<View style={{ marginBottom: spacing.section }}>
<Text
style={{
marginBottom: spacing.content.title,
fontSize: 18,
fontWeight: '600',
color: colors.foreground,
}}
>
Deck-Fortschritt
</Text>
{deckProgress.length > 0 ? (
<View style={{ gap: spacing.content.item }}>
{deckProgress.map((deck) => (
<DeckProgressCard key={deck.deck_id} progress={deck} />
))}
</View>
) : (
<Card padding="lg" variant="outlined">
<Text style={{ textAlign: 'center', color: colors.mutedForeground }}>
Noch keine Decks vorhanden
</Text>
</Card>
)}
</View>
</View>
</ScrollView>
</View>
);
}

View file

@ -5,34 +5,34 @@ import { ScrollViewStyleReset } from 'expo-router/html';
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `

View file

@ -3,22 +3,22 @@ import { Link, Stack } from 'expo-router';
import { Text, View } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View className={styles.container}>
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View className={styles.container}>
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
<Link href="/" className={styles.link}>
<Text className={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = {
container: `items-center flex-1 justify-center p-5`,
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
container: `items-center flex-1 justify-center p-5`,
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
};

View file

@ -12,70 +12,64 @@ import 'react-native-reanimated';
// Configure Reanimated logger to reduce noise (if available)
try {
const Reanimated = require('react-native-reanimated');
if (Reanimated.configureReanimatedLogger) {
Reanimated.configureReanimatedLogger({
level: Reanimated.ReanimatedLogLevel?.warn,
strict: false,
});
}
const Reanimated = require('react-native-reanimated');
if (Reanimated.configureReanimatedLogger) {
Reanimated.configureReanimatedLogger({
level: Reanimated.ReanimatedLogLevel?.warn,
strict: false,
});
}
} catch (e) {
console.warn('Reanimated logger configuration not available');
console.warn('Reanimated logger configuration not available');
}
// Ignore known warnings from third-party libraries
LogBox.ignoreLogs([
'SafeAreaView has been deprecated',
'A UIRefreshControl received offscreen',
]);
LogBox.ignoreLogs(['SafeAreaView has been deprecated', 'A UIRefreshControl received offscreen']);
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)',
};
export default function RootLayout() {
const { initialize, isInitialized, isLoading, user } = useAuthStore();
const { initialize, isInitialized, isLoading, user } = useAuthStore();
useEffect(() => {
initialize();
}, []);
useEffect(() => {
initialize();
}, []);
if (!isInitialized || isLoading) {
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<ThemeProvider>
<ThemeWrapper>
<View className="flex-1 items-center justify-center bg-surface">
<ActivityIndicator size="large" color="#3B82F6" />
</View>
</ThemeWrapper>
</ThemeProvider>
</SafeAreaProvider>
);
}
if (!isInitialized || isLoading) {
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<ThemeProvider>
<ThemeWrapper>
<View className="flex-1 items-center justify-center bg-surface">
<ActivityIndicator size="large" color="#3B82F6" />
</View>
</ThemeWrapper>
</ThemeProvider>
</SafeAreaProvider>
);
}
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<ThemeProvider>
<ThemeWrapper>
<ErrorBoundary>
{!user ? (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
</Stack>
) : (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="modal"
options={{ presentation: 'modal', headerShown: true }}
/>
</Stack>
)}
</ErrorBoundary>
</ThemeWrapper>
</ThemeProvider>
</SafeAreaProvider>
);
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<ThemeProvider>
<ThemeWrapper>
<ErrorBoundary>
{!user ? (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
</Stack>
) : (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="modal" options={{ presentation: 'modal', headerShown: true }} />
</Stack>
)}
</ErrorBoundary>
</ThemeWrapper>
</ThemeProvider>
</SafeAreaProvider>
);
}

View file

@ -9,77 +9,87 @@ import { Button } from '../../components/ui/Button';
import { useThemeColors } from '~/utils/themeUtils';
export default function CardDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { fetchCard, currentCard, isLoading } = useCardStore();
const colors = useThemeColors();
const { id } = useLocalSearchParams<{ id: string }>();
const { fetchCard, currentCard, isLoading } = useCardStore();
const colors = useThemeColors();
useEffect(() => {
if (id) {
fetchCard(id);
}
}, [id]);
useEffect(() => {
if (id) {
fetchCard(id);
}
}, [id]);
if (isLoading || !currentCard) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
<Text style={{ color: colors.mutedForeground }}>Karte wird geladen...</Text>
</View>
);
}
if (isLoading || !currentCard) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
}}
>
<Text style={{ color: colors.mutedForeground }}>Karte wird geladen...</Text>
</View>
);
}
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: currentCard.title || 'Karte',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView
style={{ flex: 1 }}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingTop: 8, paddingBottom: 200, paddingHorizontal: 16 }}>
<CardView card={currentCard} mode="view" showActions />
</ScrollView>
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: currentCard.title || 'Karte',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<ScrollView
style={{ flex: 1 }}
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingTop: 8, paddingBottom: 200, paddingHorizontal: 16 }}
>
<CardView card={currentCard} mode="view" showActions />
</ScrollView>
{/* Floating Bottom Bar */}
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(20px)',
borderTopWidth: 1,
borderTopColor: 'rgba(0, 0, 0, 0.1)',
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 32,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
}}>
<View style={{ gap: 10 }}>
<Button
onPress={() => router.push(`/card/edit/${currentCard.id}`)}
variant="primary"
fullWidth
leftIcon={<Ionicons name="create-outline" size={20} color="white" />}>
Bearbeiten
</Button>
{/* Floating Bottom Bar */}
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(20px)',
borderTopWidth: 1,
borderTopColor: 'rgba(0, 0, 0, 0.1)',
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 32,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
}}
>
<View style={{ gap: 10 }}>
<Button
onPress={() => router.push(`/card/edit/${currentCard.id}`)}
variant="primary"
fullWidth
leftIcon={<Ionicons name="create-outline" size={20} color="white" />}
>
Bearbeiten
</Button>
<Button onPress={() => router.back()} variant="outline" fullWidth>
Zurück
</Button>
</View>
</View>
</View>
</>
);
<Button onPress={() => router.back()} variant="outline" fullWidth>
Zurück
</Button>
</View>
</View>
</View>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -4,11 +4,11 @@ import { View, Text, ScrollView, KeyboardAvoidingView, Platform, Alert } from 'r
import { Stack, useLocalSearchParams, router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import {
useCardStore,
Card,
TextContent,
FlashcardContent,
QuizContent,
useCardStore,
Card,
TextContent,
FlashcardContent,
QuizContent,
} from '../../../store/cardStore';
import { Button } from '../../../components/ui/Button';
import { Input } from '../../../components/ui/Input';
@ -19,483 +19,538 @@ import { useThemeColors } from '~/utils/themeUtils';
import { spacing } from '~/utils/spacing';
export default function EditCardScreen() {
const colors = useThemeColors();
const { id } = useLocalSearchParams<{ id: string }>();
const { fetchCard, updateCard, deleteCard, currentCard, isLoading } = useCardStore();
const colors = useThemeColors();
const { id } = useLocalSearchParams<{ id: string }>();
const { fetchCard, updateCard, deleteCard, currentCard, isLoading } = useCardStore();
// Form state
const [cardType, setCardType] = useState<'text' | 'flashcard' | 'quiz' | 'mixed'>('text');
const [title, setTitle] = useState('');
const [step, setStep] = useState<'content' | 'preview'>('content');
// Form state
const [cardType, setCardType] = useState<'text' | 'flashcard' | 'quiz' | 'mixed'>('text');
const [title, setTitle] = useState('');
const [step, setStep] = useState<'content' | 'preview'>('content');
// Content state for different card types
const [textContent, setTextContent] = useState('');
const [flashcardFront, setFlashcardFront] = useState('');
const [flashcardBack, setFlashcardBack] = useState('');
const [flashcardHint, setFlashcardHint] = useState('');
const [quizQuestion, setQuizQuestion] = useState('');
const [quizOptions, setQuizOptions] = useState(['', '']);
const [quizCorrectAnswer, setQuizCorrectAnswer] = useState(0);
const [quizExplanation, setQuizExplanation] = useState('');
// Content state for different card types
const [textContent, setTextContent] = useState('');
const [flashcardFront, setFlashcardFront] = useState('');
const [flashcardBack, setFlashcardBack] = useState('');
const [flashcardHint, setFlashcardHint] = useState('');
const [quizQuestion, setQuizQuestion] = useState('');
const [quizOptions, setQuizOptions] = useState(['', '']);
const [quizCorrectAnswer, setQuizCorrectAnswer] = useState(0);
const [quizExplanation, setQuizExplanation] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [isUpdating, setIsUpdating] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (id) {
fetchCard(id);
}
}, [id]);
useEffect(() => {
if (id) {
fetchCard(id);
}
}, [id]);
// Populate form when card is loaded
useEffect(() => {
if (currentCard) {
setCardType(currentCard.card_type);
setTitle(currentCard.title || '');
// Populate form when card is loaded
useEffect(() => {
if (currentCard) {
setCardType(currentCard.card_type);
setTitle(currentCard.title || '');
switch (currentCard.card_type) {
case 'text':
const textContent = currentCard.content as TextContent;
setTextContent(textContent.text);
break;
case 'flashcard':
const flashcardContent = currentCard.content as FlashcardContent;
setFlashcardFront(flashcardContent.front);
setFlashcardBack(flashcardContent.back);
setFlashcardHint(flashcardContent.hint || '');
break;
case 'quiz':
const quizContent = currentCard.content as QuizContent;
setQuizQuestion(quizContent.question);
setQuizOptions(quizContent.options);
setQuizCorrectAnswer(quizContent.correct_answer);
setQuizExplanation(quizContent.explanation || '');
break;
}
}
}, [currentCard]);
switch (currentCard.card_type) {
case 'text':
const textContent = currentCard.content as TextContent;
setTextContent(textContent.text);
break;
case 'flashcard':
const flashcardContent = currentCard.content as FlashcardContent;
setFlashcardFront(flashcardContent.front);
setFlashcardBack(flashcardContent.back);
setFlashcardHint(flashcardContent.hint || '');
break;
case 'quiz':
const quizContent = currentCard.content as QuizContent;
setQuizQuestion(quizContent.question);
setQuizOptions(quizContent.options);
setQuizCorrectAnswer(quizContent.correct_answer);
setQuizExplanation(quizContent.explanation || '');
break;
}
}
}, [currentCard]);
const validateContent = () => {
const newErrors: Record<string, string> = {};
const validateContent = () => {
const newErrors: Record<string, string> = {};
switch (cardType) {
case 'text':
if (!textContent.trim()) {
newErrors.textContent = 'Text ist erforderlich';
}
break;
case 'flashcard':
if (!flashcardFront.trim()) {
newErrors.flashcardFront = 'Vorderseite ist erforderlich';
}
if (!flashcardBack.trim()) {
newErrors.flashcardBack = 'Rückseite ist erforderlich';
}
break;
case 'quiz':
if (!quizQuestion.trim()) {
newErrors.quizQuestion = 'Frage ist erforderlich';
}
if (quizOptions.filter((opt) => opt.trim()).length < 2) {
newErrors.quizOptions = 'Mindestens 2 Antwortoptionen erforderlich';
}
break;
}
switch (cardType) {
case 'text':
if (!textContent.trim()) {
newErrors.textContent = 'Text ist erforderlich';
}
break;
case 'flashcard':
if (!flashcardFront.trim()) {
newErrors.flashcardFront = 'Vorderseite ist erforderlich';
}
if (!flashcardBack.trim()) {
newErrors.flashcardBack = 'Rückseite ist erforderlich';
}
break;
case 'quiz':
if (!quizQuestion.trim()) {
newErrors.quizQuestion = 'Frage ist erforderlich';
}
if (quizOptions.filter((opt) => opt.trim()).length < 2) {
newErrors.quizOptions = 'Mindestens 2 Antwortoptionen erforderlich';
}
break;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const getCardContent = () => {
switch (cardType) {
case 'text':
return { text: textContent } as TextContent;
case 'flashcard':
return {
front: flashcardFront,
back: flashcardBack,
hint: flashcardHint || undefined,
} as FlashcardContent;
case 'quiz':
return {
question: quizQuestion,
options: quizOptions.filter((opt) => opt.trim()),
correct_answer: quizCorrectAnswer,
explanation: quizExplanation || undefined,
} as QuizContent;
default:
return { text: '' } as TextContent;
}
};
const getCardContent = () => {
switch (cardType) {
case 'text':
return { text: textContent } as TextContent;
case 'flashcard':
return {
front: flashcardFront,
back: flashcardBack,
hint: flashcardHint || undefined,
} as FlashcardContent;
case 'quiz':
return {
question: quizQuestion,
options: quizOptions.filter((opt) => opt.trim()),
correct_answer: quizCorrectAnswer,
explanation: quizExplanation || undefined,
} as QuizContent;
default:
return { text: '' } as TextContent;
}
};
const getPreviewCard = (): Card => {
if (!currentCard) {
return {
id: 'preview',
deck_id: '',
position: 1,
title: title || undefined,
content: getCardContent(),
card_type: cardType,
version: 1,
is_favorite: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
const getPreviewCard = (): Card => {
if (!currentCard) {
return {
id: 'preview',
deck_id: '',
position: 1,
title: title || undefined,
content: getCardContent(),
card_type: cardType,
version: 1,
is_favorite: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
return {
...currentCard,
title: title || undefined,
content: getCardContent(),
card_type: cardType,
};
};
return {
...currentCard,
title: title || undefined,
content: getCardContent(),
card_type: cardType,
};
};
const handleNext = () => {
if (step === 'content') {
if (validateContent()) {
setStep('preview');
}
}
};
const handleNext = () => {
if (step === 'content') {
if (validateContent()) {
setStep('preview');
}
}
};
const handleUpdate = async () => {
if (!validateContent() || !currentCard) return;
const handleUpdate = async () => {
if (!validateContent() || !currentCard) return;
try {
setIsUpdating(true);
try {
setIsUpdating(true);
await updateCard(currentCard.id, {
title: title || undefined,
content: getCardContent(),
card_type: cardType,
});
await updateCard(currentCard.id, {
title: title || undefined,
content: getCardContent(),
card_type: cardType,
});
router.back();
} catch (error: any) {
Alert.alert('Fehler', error.message || 'Karte konnte nicht aktualisiert werden');
} finally {
setIsUpdating(false);
}
};
router.back();
} catch (error: any) {
Alert.alert('Fehler', error.message || 'Karte konnte nicht aktualisiert werden');
} finally {
setIsUpdating(false);
}
};
const handleDelete = () => {
if (!currentCard) return;
const handleDelete = () => {
if (!currentCard) return;
Alert.alert(
'Karte löschen',
'Möchtest du diese Karte wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: async () => {
try {
await deleteCard(currentCard.id);
router.back();
} catch (error: any) {
Alert.alert('Fehler', error.message || 'Karte konnte nicht gelöscht werden');
}
},
},
]
);
};
Alert.alert(
'Karte löschen',
'Möchtest du diese Karte wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: async () => {
try {
await deleteCard(currentCard.id);
router.back();
} catch (error: any) {
Alert.alert('Fehler', error.message || 'Karte konnte nicht gelöscht werden');
}
},
},
]
);
};
const addQuizOption = () => {
if (quizOptions.length < 6) {
setQuizOptions([...quizOptions, '']);
}
};
const addQuizOption = () => {
if (quizOptions.length < 6) {
setQuizOptions([...quizOptions, '']);
}
};
const removeQuizOption = (index: number) => {
if (quizOptions.length > 2) {
const newOptions = quizOptions.filter((_, i) => i !== index);
setQuizOptions(newOptions);
if (quizCorrectAnswer >= newOptions.length) {
setQuizCorrectAnswer(newOptions.length - 1);
}
}
};
const removeQuizOption = (index: number) => {
if (quizOptions.length > 2) {
const newOptions = quizOptions.filter((_, i) => i !== index);
setQuizOptions(newOptions);
if (quizCorrectAnswer >= newOptions.length) {
setQuizCorrectAnswer(newOptions.length - 1);
}
}
};
const updateQuizOption = (index: number, value: string) => {
const newOptions = [...quizOptions];
newOptions[index] = value;
setQuizOptions(newOptions);
};
const updateQuizOption = (index: number, value: string) => {
const newOptions = [...quizOptions];
newOptions[index] = value;
setQuizOptions(newOptions);
};
const renderContentStep = () => (
<UICard padding="lg" variant="elevated">
<View style={{ marginBottom: spacing.content.title, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>Karte bearbeiten</Text>
<CardTypeSelector
selectedType={cardType}
onTypeChange={setCardType}
compact
showDescriptions={false}
/>
</View>
const renderContentStep = () => (
<UICard padding="lg" variant="elevated">
<View
style={{
marginBottom: spacing.content.title,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Text style={{ fontSize: 18, fontWeight: '600', color: colors.foreground }}>
Karte bearbeiten
</Text>
<CardTypeSelector
selectedType={cardType}
onTypeChange={setCardType}
compact
showDescriptions={false}
/>
</View>
<Input
label="Titel (optional)"
placeholder="z.B. Wichtige Formel"
value={title}
onChangeText={setTitle}
leftIcon="text-outline"
containerClassName="mb-4"
/>
<Input
label="Titel (optional)"
placeholder="z.B. Wichtige Formel"
value={title}
onChangeText={setTitle}
leftIcon="text-outline"
containerClassName="mb-4"
/>
{cardType === 'text' && (
<Input
label="Text"
placeholder="Gib deinen Karteninhalt ein..."
value={textContent}
onChangeText={setTextContent}
error={errors.textContent}
leftIcon="document-text-outline"
multiline
numberOfLines={6}
textAlignVertical="top"
/>
)}
{cardType === 'text' && (
<Input
label="Text"
placeholder="Gib deinen Karteninhalt ein..."
value={textContent}
onChangeText={setTextContent}
error={errors.textContent}
leftIcon="document-text-outline"
multiline
numberOfLines={6}
textAlignVertical="top"
/>
)}
{cardType === 'flashcard' && (
<>
<Input
label="Vorderseite (Frage)"
placeholder="z.B. Was ist die Hauptstadt von Deutschland?"
value={flashcardFront}
onChangeText={setFlashcardFront}
error={errors.flashcardFront}
leftIcon="help-circle-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
containerClassName="mb-4"
/>
<Input
label="Rückseite (Antwort)"
placeholder="z.B. Berlin"
value={flashcardBack}
onChangeText={setFlashcardBack}
error={errors.flashcardBack}
leftIcon="checkmark-circle-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
containerClassName="mb-4"
/>
<Input
label="Hinweis (optional)"
placeholder="z.B. Es ist auch das größte Bundesland"
value={flashcardHint}
onChangeText={setFlashcardHint}
leftIcon="bulb-outline"
multiline
numberOfLines={2}
textAlignVertical="top"
/>
</>
)}
{cardType === 'flashcard' && (
<>
<Input
label="Vorderseite (Frage)"
placeholder="z.B. Was ist die Hauptstadt von Deutschland?"
value={flashcardFront}
onChangeText={setFlashcardFront}
error={errors.flashcardFront}
leftIcon="help-circle-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
containerClassName="mb-4"
/>
<Input
label="Rückseite (Antwort)"
placeholder="z.B. Berlin"
value={flashcardBack}
onChangeText={setFlashcardBack}
error={errors.flashcardBack}
leftIcon="checkmark-circle-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
containerClassName="mb-4"
/>
<Input
label="Hinweis (optional)"
placeholder="z.B. Es ist auch das größte Bundesland"
value={flashcardHint}
onChangeText={setFlashcardHint}
leftIcon="bulb-outline"
multiline
numberOfLines={2}
textAlignVertical="top"
/>
</>
)}
{cardType === 'quiz' && (
<>
<Input
label="Frage"
placeholder="z.B. Welche ist die richtige Antwort?"
value={quizQuestion}
onChangeText={setQuizQuestion}
error={errors.quizQuestion}
leftIcon="help-circle-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
containerClassName="mb-4"
/>
{cardType === 'quiz' && (
<>
<Input
label="Frage"
placeholder="z.B. Welche ist die richtige Antwort?"
value={quizQuestion}
onChangeText={setQuizQuestion}
error={errors.quizQuestion}
leftIcon="help-circle-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
containerClassName="mb-4"
/>
<Text style={{ marginBottom: spacing.sm, fontSize: 14, fontWeight: '500', color: colors.foreground }}>
Antwortoptionen{' '}
{errors.quizOptions && <Text style={{ color: '#EF4444' }}>- {errors.quizOptions}</Text>}
</Text>
<Text
style={{
marginBottom: spacing.sm,
fontSize: 14,
fontWeight: '500',
color: colors.foreground,
}}
>
Antwortoptionen{' '}
{errors.quizOptions && <Text style={{ color: '#EF4444' }}>- {errors.quizOptions}</Text>}
</Text>
{quizOptions.map((option, index) => (
<View key={index} style={{ marginBottom: spacing.sm, flexDirection: 'row', alignItems: 'center' }}>
<View style={{ marginRight: 8, flex: 1 }}>
<Input
placeholder={`Option ${index + 1}`}
value={option}
onChangeText={(value) => updateQuizOption(index, value)}
leftIcon={
quizCorrectAnswer === index ? 'checkmark-circle' : 'radio-button-off-outline'
}
onLeftIconPress={() => setQuizCorrectAnswer(index)}
containerClassName="mb-0"
/>
</View>
{quizOptions.length > 2 && (
<Button onPress={() => removeQuizOption(index)} variant="ghost" size="sm">
<Ionicons name="trash-outline" size={20} color="#EF4444" />
</Button>
)}
</View>
))}
{quizOptions.map((option, index) => (
<View
key={index}
style={{ marginBottom: spacing.sm, flexDirection: 'row', alignItems: 'center' }}
>
<View style={{ marginRight: 8, flex: 1 }}>
<Input
placeholder={`Option ${index + 1}`}
value={option}
onChangeText={(value) => updateQuizOption(index, value)}
leftIcon={
quizCorrectAnswer === index ? 'checkmark-circle' : 'radio-button-off-outline'
}
onLeftIconPress={() => setQuizCorrectAnswer(index)}
containerClassName="mb-0"
/>
</View>
{quizOptions.length > 2 && (
<Button onPress={() => removeQuizOption(index)} variant="ghost" size="sm">
<Ionicons name="trash-outline" size={20} color="#EF4444" />
</Button>
)}
</View>
))}
{quizOptions.length < 6 && (
<Button
onPress={addQuizOption}
variant="outline"
size="sm"
leftIcon={<Ionicons name="add" size={16} color="#374151" />}
className="mb-4">
Option hinzufügen
</Button>
)}
{quizOptions.length < 6 && (
<Button
onPress={addQuizOption}
variant="outline"
size="sm"
leftIcon={<Ionicons name="add" size={16} color="#374151" />}
className="mb-4"
>
Option hinzufügen
</Button>
)}
<Input
label="Erklärung (optional)"
placeholder="Warum ist diese Antwort richtig?"
value={quizExplanation}
onChangeText={setQuizExplanation}
leftIcon="information-circle-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
/>
</>
)}
</UICard>
);
<Input
label="Erklärung (optional)"
placeholder="Warum ist diese Antwort richtig?"
value={quizExplanation}
onChangeText={setQuizExplanation}
leftIcon="information-circle-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
/>
</>
)}
</UICard>
);
const renderPreviewStep = () => (
<UICard padding="lg" variant="elevated">
<Text style={{ marginBottom: spacing.content.title, fontSize: 18, fontWeight: '600', color: colors.foreground }}>Vorschau</Text>
<CardView card={getPreviewCard()} mode="preview" />
</UICard>
);
const renderPreviewStep = () => (
<UICard padding="lg" variant="elevated">
<Text
style={{
marginBottom: spacing.content.title,
fontSize: 18,
fontWeight: '600',
color: colors.foreground,
}}
>
Vorschau
</Text>
<CardView card={getPreviewCard()} mode="preview" />
</UICard>
);
if (isLoading || !currentCard) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
<Text style={{ color: colors.mutedForeground }}>Karte wird geladen...</Text>
</View>
);
}
if (isLoading || !currentCard) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
}}
>
<Text style={{ color: colors.mutedForeground }}>Karte wird geladen...</Text>
</View>
);
}
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: step === 'content' ? 'Karte bearbeiten' : 'Vorschau',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Ionicons
name="close"
size={24}
color={colors.foreground}
onPress={() => router.back()}
style={{ marginLeft: 10 }}
/>
),
headerRight: () => (
<Ionicons
name="trash-outline"
size={24}
color="#EF4444"
onPress={handleDelete}
style={{ marginRight: 10 }}
/>
),
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}>
{/* Progress Indicator */}
<View style={{ borderBottomWidth: 1, borderBottomColor: colors.border, backgroundColor: colors.surfaceElevated, paddingHorizontal: 16, paddingVertical: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
marginRight: 8,
height: 32,
width: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 16,
backgroundColor: step === 'content' ? colors.primary : '#10B981',
}}>
<Text style={{ fontSize: 14, fontWeight: 'bold', color: '#FFFFFF' }}>1</Text>
</View>
<View
style={{
marginHorizontal: 8,
height: 4,
flex: 1,
backgroundColor: step === 'preview' ? '#10B981' : colors.border,
}}
/>
<View
style={{
height: 32,
width: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 16,
backgroundColor: step === 'preview' ? colors.primary : colors.border,
}}>
<Text
style={{
fontSize: 14,
fontWeight: 'bold',
color: step === 'preview' ? '#FFFFFF' : colors.mutedForeground,
}}>
2
</Text>
</View>
</View>
</View>
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: step === 'content' ? 'Karte bearbeiten' : 'Vorschau',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Ionicons
name="close"
size={24}
color={colors.foreground}
onPress={() => router.back()}
style={{ marginLeft: 10 }}
/>
),
headerRight: () => (
<Ionicons
name="trash-outline"
size={24}
color="#EF4444"
onPress={handleDelete}
style={{ marginRight: 10 }}
/>
),
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
{/* Progress Indicator */}
<View
style={{
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.surfaceElevated,
paddingHorizontal: 16,
paddingVertical: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
marginRight: 8,
height: 32,
width: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 16,
backgroundColor: step === 'content' ? colors.primary : '#10B981',
}}
>
<Text style={{ fontSize: 14, fontWeight: 'bold', color: '#FFFFFF' }}>1</Text>
</View>
<View
style={{
marginHorizontal: 8,
height: 4,
flex: 1,
backgroundColor: step === 'preview' ? '#10B981' : colors.border,
}}
/>
<View
style={{
height: 32,
width: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 16,
backgroundColor: step === 'preview' ? colors.primary : colors.border,
}}
>
<Text
style={{
fontSize: 14,
fontWeight: 'bold',
color: step === 'preview' ? '#FFFFFF' : colors.mutedForeground,
}}
>
2
</Text>
</View>
</View>
</View>
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
<View style={{ paddingHorizontal: spacing.container.horizontal, paddingVertical: spacing.container.vertical }}>
{step === 'content' && renderContentStep()}
{step === 'preview' && renderPreviewStep()}
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
<View
style={{
paddingHorizontal: spacing.container.horizontal,
paddingVertical: spacing.container.vertical,
}}
>
{step === 'content' && renderContentStep()}
{step === 'preview' && renderPreviewStep()}
{/* Navigation Buttons */}
<View style={{ marginTop: 24, gap: 12 }}>
{step === 'content' && (
<Button onPress={handleNext} fullWidth size="lg">
Vorschau
</Button>
)}
{/* Navigation Buttons */}
<View style={{ marginTop: 24, gap: 12 }}>
{step === 'content' && (
<Button onPress={handleNext} fullWidth size="lg">
Vorschau
</Button>
)}
{step === 'preview' && (
<View style={{ gap: spacing.content.small }}>
<Button
onPress={() => setStep('content')}
variant="outline"
fullWidth
size="lg">
Bearbeiten
</Button>
<Button
onPress={handleUpdate}
loading={isUpdating}
variant="primary"
fullWidth
size="lg">
Speichern
</Button>
</View>
)}
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</View>
</>
);
{step === 'preview' && (
<View style={{ gap: spacing.content.small }}>
<Button
onPress={() => setStep('content')}
variant="outline"
fullWidth
size="lg"
>
Bearbeiten
</Button>
<Button
onPress={handleUpdate}
loading={isUpdating}
variant="primary"
fullWidth
size="lg"
>
Speichern
</Button>
</View>
)}
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</View>
</>
);
}

View file

@ -13,153 +13,175 @@ import { useStudyStore } from '../../store/studyStore';
import { spacing } from '~/utils/spacing';
export default function DeckDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentDeck, fetchDeck, toggleFavorite, isLoading } = useDeckStore();
const { cards, fetchCards } = useCardStore();
const { startSession, fetchCardProgress, cardProgressMap } = useStudyStore();
const [showModeSelector, setShowModeSelector] = useState(false);
const colors = useThemeColors();
useEffect(() => {
if (id) {
fetchDeck(id);
fetchCards(id);
fetchCardProgress(id);
}
}, [id]);
const { id } = useLocalSearchParams<{ id: string }>();
const { currentDeck, fetchDeck, toggleFavorite, isLoading } = useDeckStore();
const { cards, fetchCards } = useCardStore();
const { startSession, fetchCardProgress, cardProgressMap } = useStudyStore();
const [showModeSelector, setShowModeSelector] = useState(false);
const colors = useThemeColors();
useEffect(() => {
if (id) {
fetchDeck(id);
fetchCards(id);
fetchCardProgress(id);
}
}, [id]);
const handleToggleFavorite = async () => {
if (currentDeck) {
await toggleFavorite(currentDeck.id);
// Refetch to get updated data
await fetchDeck(currentDeck.id);
}
};
const handleToggleFavorite = async () => {
if (currentDeck) {
await toggleFavorite(currentDeck.id);
// Refetch to get updated data
await fetchDeck(currentDeck.id);
}
};
const handleStartStudy = async (mode: StudyMode) => {
if (!id) return;
await startSession(id, mode);
router.push(`/study/session/${id}` as any);
};
const handleStartStudy = async (mode: StudyMode) => {
if (!id) return;
await startSession(id, mode);
router.push(`/study/session/${id}` as any);
};
if (isLoading || !currentDeck) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<Stack.Screen
options={{
headerShown: true,
title: 'Lädt...',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={colors.foreground} />
</Pressable>
),
}}
/>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (isLoading || !currentDeck) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.background }} edges={['top']}>
<Stack.Screen
options={{
headerShown: true,
title: 'Lädt...',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={colors.foreground} />
</Pressable>
),
}}
/>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
const isFavorite = currentDeck.metadata?.is_favorite || false;
const isFavorite = currentDeck.metadata?.is_favorite || false;
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<Stack.Screen
options={{
headerShown: true,
title: currentDeck.title,
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerRight: () => (
<View style={{ flexDirection: 'row' }}>
<Pressable onPress={handleToggleFavorite} style={{ marginRight: 16 }}>
<Ionicons
name={isFavorite ? 'heart' : 'heart-outline'}
size={24}
color={isFavorite ? colors.destructive : colors.mutedForeground}
/>
</Pressable>
<Pressable onPress={() => router.push(`/deck/${id}/edit`)}>
<Ionicons name="create-outline" size={24} color={colors.mutedForeground} />
</Pressable>
</View>
),
headerLeft: () => (
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={colors.foreground} />
</Pressable>
),
}}
/>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingTop: spacing.sm, paddingBottom: 200, paddingHorizontal: spacing.container.horizontal }}>
<CardList deckId={currentDeck.id} isCompact={true} showActions={false} />
</ScrollView>
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<Stack.Screen
options={{
headerShown: true,
title: currentDeck.title,
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerRight: () => (
<View style={{ flexDirection: 'row' }}>
<Pressable onPress={handleToggleFavorite} style={{ marginRight: 16 }}>
<Ionicons
name={isFavorite ? 'heart' : 'heart-outline'}
size={24}
color={isFavorite ? colors.destructive : colors.mutedForeground}
/>
</Pressable>
<Pressable onPress={() => router.push(`/deck/${id}/edit`)}>
<Ionicons name="create-outline" size={24} color={colors.mutedForeground} />
</Pressable>
</View>
),
headerLeft: () => (
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={colors.foreground} />
</Pressable>
),
}}
/>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: spacing.sm,
paddingBottom: 200,
paddingHorizontal: spacing.container.horizontal,
}}
>
<CardList deckId={currentDeck.id} isCompact={true} showActions={false} />
</ScrollView>
{/* Floating Bottom Glass Bar */}
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(20px)',
borderTopWidth: 1,
borderTopColor: 'rgba(0, 0, 0, 0.1)',
paddingHorizontal: spacing.container.horizontal,
paddingTop: spacing.lg,
paddingBottom: spacing.xxl,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
}}>
{currentDeck.description && (
<Text style={{ marginBottom: spacing.content.small, fontSize: 14, lineHeight: 20, color: 'rgba(0, 0, 0, 0.7)' }} numberOfLines={2}>
{currentDeck.description}
</Text>
)}
{/* Floating Bottom Glass Bar */}
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(20px)',
borderTopWidth: 1,
borderTopColor: 'rgba(0, 0, 0, 0.1)',
paddingHorizontal: spacing.container.horizontal,
paddingTop: spacing.lg,
paddingBottom: spacing.xxl,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 8,
}}
>
{currentDeck.description && (
<Text
style={{
marginBottom: spacing.content.small,
fontSize: 14,
lineHeight: 20,
color: 'rgba(0, 0, 0, 0.7)',
}}
numberOfLines={2}
>
{currentDeck.description}
</Text>
)}
<View style={{ marginBottom: spacing.content.small, flexDirection: 'row', alignItems: 'center' }}>
<Ionicons name="layers-outline" size={16} color="rgba(0, 0, 0, 0.6)" />
<Text style={{ marginLeft: 6, fontSize: 14, color: 'rgba(0, 0, 0, 0.6)' }}>
{cards.length} {cards.length === 1 ? 'Karte' : 'Karten'}
</Text>
</View>
<View
style={{
marginBottom: spacing.content.small,
flexDirection: 'row',
alignItems: 'center',
}}
>
<Ionicons name="layers-outline" size={16} color="rgba(0, 0, 0, 0.6)" />
<Text style={{ marginLeft: 6, fontSize: 14, color: 'rgba(0, 0, 0, 0.6)' }}>
{cards.length} {cards.length === 1 ? 'Karte' : 'Karten'}
</Text>
</View>
{/* Action Buttons */}
<View style={{ gap: spacing.sm }}>
<Button
onPress={() => setShowModeSelector(true)}
variant="primary"
fullWidth
leftIcon={<Ionicons name="play-circle-outline" size={20} color="white" />}
disabled={cards.length === 0}>
Lernen starten
</Button>
<Button
onPress={() => router.push(`/card/create?deckId=${currentDeck.id}`)}
variant="outline"
fullWidth
leftIcon={<Ionicons name="add-circle-outline" size={20} color={colors.foreground} />}>
Karte hinzufügen
</Button>
</View>
</View>
{/* Action Buttons */}
<View style={{ gap: spacing.sm }}>
<Button
onPress={() => setShowModeSelector(true)}
variant="primary"
fullWidth
leftIcon={<Ionicons name="play-circle-outline" size={20} color="white" />}
disabled={cards.length === 0}
>
Lernen starten
</Button>
<Button
onPress={() => router.push(`/card/create?deckId=${currentDeck.id}`)}
variant="outline"
fullWidth
leftIcon={<Ionicons name="add-circle-outline" size={20} color={colors.foreground} />}
>
Karte hinzufügen
</Button>
</View>
</View>
{/* Study Mode Selector Modal */}
<StudyModeSelector
visible={showModeSelector}
onClose={() => setShowModeSelector(false)}
onSelectMode={handleStartStudy}
/>
</View>
);
{/* Study Mode Selector Modal */}
<StudyModeSelector
visible={showModeSelector}
onClose={() => setShowModeSelector(false)}
onSelectMode={handleStartStudy}
/>
</View>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,12 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
KeyboardAvoidingView,
Platform,
Switch,
Alert,
View,
Text,
ScrollView,
KeyboardAvoidingView,
Platform,
Switch,
Alert,
} from 'react-native';
import { Stack, useLocalSearchParams, router } from 'expo-router';
@ -19,212 +19,253 @@ import { useThemeColors } from '~/utils/themeUtils';
import { spacing } from '~/utils/spacing';
export default function EditDeckScreen() {
const colors = useThemeColors();
const { id } = useLocalSearchParams<{ id: string }>();
const { currentDeck, fetchDeck, updateDeck, isLoading } = useDeckStore();
const colors = useThemeColors();
const { id } = useLocalSearchParams<{ id: string }>();
const { currentDeck, fetchDeck, updateDeck, isLoading } = useDeckStore();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(false);
const [tags, setTags] = useState('');
const [errors, setErrors] = useState<{ title?: string }>({});
const [isUpdating, setIsUpdating] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(false);
const [tags, setTags] = useState('');
const [errors, setErrors] = useState<{ title?: string }>({});
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (id && !currentDeck) {
fetchDeck(id);
}
}, [id]);
useEffect(() => {
if (id && !currentDeck) {
fetchDeck(id);
}
}, [id]);
useEffect(() => {
if (currentDeck) {
setTitle(currentDeck.title);
setDescription(currentDeck.description || '');
setIsPublic(currentDeck.is_public);
setTags(currentDeck.tags?.join(', ') || '');
}
}, [currentDeck]);
useEffect(() => {
if (currentDeck) {
setTitle(currentDeck.title);
setDescription(currentDeck.description || '');
setIsPublic(currentDeck.is_public);
setTags(currentDeck.tags?.join(', ') || '');
}
}, [currentDeck]);
const validateForm = () => {
const newErrors: { title?: string } = {};
const validateForm = () => {
const newErrors: { title?: string } = {};
if (!title.trim()) {
newErrors.title = 'Titel ist erforderlich';
} else if (title.length < 3) {
newErrors.title = 'Titel muss mindestens 3 Zeichen lang sein';
}
if (!title.trim()) {
newErrors.title = 'Titel ist erforderlich';
} else if (title.length < 3) {
newErrors.title = 'Titel muss mindestens 3 Zeichen lang sein';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleUpdate = async () => {
if (!validateForm() || !currentDeck) return;
const handleUpdate = async () => {
if (!validateForm() || !currentDeck) return;
try {
setIsUpdating(true);
try {
setIsUpdating(true);
await updateDeck(currentDeck.id, {
title: title.trim(),
description: description.trim() || undefined,
is_public: isPublic,
tags: tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
});
await updateDeck(currentDeck.id, {
title: title.trim(),
description: description.trim() || undefined,
is_public: isPublic,
tags: tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
});
Alert.alert('Erfolg', 'Deck wurde aktualisiert', [
{
text: 'OK',
onPress: () => router.back(),
},
]);
} catch (error: any) {
Alert.alert('Fehler', error.message || 'Deck konnte nicht aktualisiert werden');
} finally {
setIsUpdating(false);
}
};
Alert.alert('Erfolg', 'Deck wurde aktualisiert', [
{
text: 'OK',
onPress: () => router.back(),
},
]);
} catch (error: any) {
Alert.alert('Fehler', error.message || 'Deck konnte nicht aktualisiert werden');
} finally {
setIsUpdating(false);
}
};
if (!currentDeck) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<Stack.Screen
options={{
headerShown: true,
title: 'Deck bearbeiten',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Ionicons name="close" size={24} color={colors.foreground} onPress={() => router.back()} />
),
}}
/>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.mutedForeground }}>Lädt...</Text>
</View>
</View>
);
}
if (!currentDeck) {
return (
<View style={{ flex: 1, backgroundColor: colors.background }}>
<Stack.Screen
options={{
headerShown: true,
title: 'Deck bearbeiten',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Ionicons
name="close"
size={24}
color={colors.foreground}
onPress={() => router.back()}
/>
),
}}
/>
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.mutedForeground }}>Lädt...</Text>
</View>
</View>
);
}
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: 'Deck bearbeiten',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Ionicons
name="close"
size={24}
color={colors.foreground}
onPress={() => router.back()}
style={{ marginLeft: 10 }}
/>
),
headerRight: () => (
<Button
onPress={handleUpdate}
loading={isUpdating}
variant="primary"
size="sm"
style={{ marginRight: 10 }}>
Speichern
</Button>
),
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}>
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
<View style={{ paddingHorizontal: spacing.container.horizontal, paddingVertical: spacing.container.vertical }}>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<Input
label="Titel"
placeholder="z.B. Spanisch Grundlagen"
value={title}
onChangeText={setTitle}
error={errors.title}
leftIcon="text-outline"
/>
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: 'Deck bearbeiten',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Ionicons
name="close"
size={24}
color={colors.foreground}
onPress={() => router.back()}
style={{ marginLeft: 10 }}
/>
),
headerRight: () => (
<Button
onPress={handleUpdate}
loading={isUpdating}
variant="primary"
size="sm"
style={{ marginRight: 10 }}
>
Speichern
</Button>
),
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
<View
style={{
paddingHorizontal: spacing.container.horizontal,
paddingVertical: spacing.container.vertical,
}}
>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<Input
label="Titel"
placeholder="z.B. Spanisch Grundlagen"
value={title}
onChangeText={setTitle}
error={errors.title}
leftIcon="text-outline"
/>
<Input
label="Beschreibung (optional)"
placeholder="Worum geht es in diesem Deck?"
value={description}
onChangeText={setDescription}
leftIcon="document-text-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
/>
<Input
label="Beschreibung (optional)"
placeholder="Worum geht es in diesem Deck?"
value={description}
onChangeText={setDescription}
leftIcon="document-text-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
/>
<Input
label="Tags (optional)"
placeholder="spanisch, grundlagen, vokabeln"
value={tags}
onChangeText={setTags}
leftIcon="pricetags-outline"
autoCapitalize="none"
/>
</Card>
<Input
label="Tags (optional)"
placeholder="spanisch, grundlagen, vokabeln"
value={tags}
onChangeText={setTags}
leftIcon="pricetags-outline"
autoCapitalize="none"
/>
</Card>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<View style={{ marginBottom: spacing.sm, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ marginRight: spacing.content.small, flex: 1 }}>
<Text style={{ fontSize: 16, fontWeight: '500', color: colors.foreground }}>Öffentliches Deck</Text>
<Text style={{ marginTop: spacing.xs, fontSize: 14, color: colors.mutedForeground }}>
Andere Nutzer können dein Deck sehen und lernen
</Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: '#CBD5E1', true: '#93C5FD' }}
thumbColor={isPublic ? '#3B82F6' : '#F3F4F6'}
/>
</View>
</Card>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<View
style={{
marginBottom: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ marginRight: spacing.content.small, flex: 1 }}>
<Text style={{ fontSize: 16, fontWeight: '500', color: colors.foreground }}>
Öffentliches Deck
</Text>
<Text
style={{
marginTop: spacing.xs,
fontSize: 14,
color: colors.mutedForeground,
}}
>
Andere Nutzer können dein Deck sehen und lernen
</Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: '#CBD5E1', true: '#93C5FD' }}
thumbColor={isPublic ? '#3B82F6' : '#F3F4F6'}
/>
</View>
</Card>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<Text style={{ marginBottom: spacing.content.small, fontSize: 16, fontWeight: '500', color: colors.foreground }}>Statistiken</Text>
<View style={{ gap: spacing.sm }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, color: colors.mutedForeground }}>Karten:</Text>
<Text style={{ fontSize: 14, color: colors.foreground }}>{currentDeck.card_count || 0}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, color: colors.mutedForeground }}>Erstellt:</Text>
<Text style={{ fontSize: 14, color: colors.foreground }}>
{new Date(currentDeck.created_at).toLocaleDateString('de-DE')}
</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, color: colors.mutedForeground }}>Zuletzt bearbeitet:</Text>
<Text style={{ fontSize: 14, color: colors.foreground }}>
{new Date(currentDeck.updated_at).toLocaleDateString('de-DE')}
</Text>
</View>
</View>
</Card>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<Text
style={{
marginBottom: spacing.content.small,
fontSize: 16,
fontWeight: '500',
color: colors.foreground,
}}
>
Statistiken
</Text>
<View style={{ gap: spacing.sm }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, color: colors.mutedForeground }}>Karten:</Text>
<Text style={{ fontSize: 14, color: colors.foreground }}>
{currentDeck.card_count || 0}
</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, color: colors.mutedForeground }}>Erstellt:</Text>
<Text style={{ fontSize: 14, color: colors.foreground }}>
{new Date(currentDeck.created_at).toLocaleDateString('de-DE')}
</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, color: colors.mutedForeground }}>
Zuletzt bearbeitet:
</Text>
<Text style={{ fontSize: 14, color: colors.foreground }}>
{new Date(currentDeck.updated_at).toLocaleDateString('de-DE')}
</Text>
</View>
</View>
</Card>
<Button
onPress={handleUpdate}
loading={isUpdating}
fullWidth
size="lg"
leftIcon={<Ionicons name="save-outline" size={20} color="white" />}>
Änderungen speichern
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
</View>
</>
);
<Button
onPress={handleUpdate}
loading={isUpdating}
fullWidth
size="lg"
leftIcon={<Ionicons name="save-outline" size={20} color="white" />}
>
Änderungen speichern
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
</View>
</>
);
}

View file

@ -1,12 +1,12 @@
import React, { useState } from 'react';
import {
View,
Text,
ScrollView,
KeyboardAvoidingView,
Platform,
Switch,
Alert,
View,
Text,
ScrollView,
KeyboardAvoidingView,
Platform,
Switch,
Alert,
} from 'react-native';
import { router, Stack } from 'expo-router';
@ -19,141 +19,163 @@ import { useThemeColors } from '~/utils/themeUtils';
import { spacing } from '~/utils/spacing';
export default function CreateDeckScreen() {
const colors = useThemeColors();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(false);
const [tags, setTags] = useState('');
const [errors, setErrors] = useState<{ title?: string }>({});
const colors = useThemeColors();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(false);
const [tags, setTags] = useState('');
const [errors, setErrors] = useState<{ title?: string }>({});
const { createDeck, isLoading } = useDeckStore();
const { createDeck, isLoading } = useDeckStore();
const validateForm = () => {
const newErrors: { title?: string } = {};
const validateForm = () => {
const newErrors: { title?: string } = {};
if (!title.trim()) {
newErrors.title = 'Titel ist erforderlich';
} else if (title.length < 3) {
newErrors.title = 'Titel muss mindestens 3 Zeichen lang sein';
}
if (!title.trim()) {
newErrors.title = 'Titel ist erforderlich';
} else if (title.length < 3) {
newErrors.title = 'Titel muss mindestens 3 Zeichen lang sein';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleCreate = async () => {
if (!validateForm()) return;
const handleCreate = async () => {
if (!validateForm()) return;
try {
const deck = await createDeck({
title: title.trim(),
description: description.trim() || undefined,
is_public: isPublic,
tags: tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
});
try {
const deck = await createDeck({
title: title.trim(),
description: description.trim() || undefined,
is_public: isPublic,
tags: tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
});
Alert.alert('Deck erstellt!', 'Dein Deck wurde erfolgreich erstellt.', [
{
text: 'OK',
onPress: () => router.replace(`/deck/${deck.id}`),
},
]);
} catch (error: any) {
Alert.alert('Fehler', error.message || 'Deck konnte nicht erstellt werden');
}
};
Alert.alert('Deck erstellt!', 'Dein Deck wurde erfolgreich erstellt.', [
{
text: 'OK',
onPress: () => router.replace(`/deck/${deck.id}`),
},
]);
} catch (error: any) {
Alert.alert('Fehler', error.message || 'Deck konnte nicht erstellt werden');
}
};
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: 'Neues Deck',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Icon
name="close"
size={24}
color={colors.foreground}
onPress={() => router.back()}
style={{ marginLeft: 10 }}
library="Ionicons"
/>
),
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}>
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
<View style={{ paddingHorizontal: spacing.container.horizontal, paddingVertical: spacing.container.vertical }}>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<Input
label="Titel"
placeholder="z.B. Spanisch Grundlagen"
value={title}
onChangeText={setTitle}
error={errors.title}
leftIcon="text-outline"
/>
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: 'Neues Deck',
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.foreground,
headerLeft: () => (
<Icon
name="close"
size={24}
color={colors.foreground}
onPress={() => router.back()}
style={{ marginLeft: 10 }}
library="Ionicons"
/>
),
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
<View
style={{
paddingHorizontal: spacing.container.horizontal,
paddingVertical: spacing.container.vertical,
}}
>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<Input
label="Titel"
placeholder="z.B. Spanisch Grundlagen"
value={title}
onChangeText={setTitle}
error={errors.title}
leftIcon="text-outline"
/>
<Input
label="Beschreibung (optional)"
placeholder="Worum geht es in diesem Deck?"
value={description}
onChangeText={setDescription}
leftIcon="document-text-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
/>
<Input
label="Beschreibung (optional)"
placeholder="Worum geht es in diesem Deck?"
value={description}
onChangeText={setDescription}
leftIcon="document-text-outline"
multiline
numberOfLines={3}
textAlignVertical="top"
/>
<Input
label="Tags (optional)"
placeholder="spanisch, grundlagen, vokabeln"
value={tags}
onChangeText={setTags}
leftIcon="pricetags-outline"
autoCapitalize="none"
/>
</Card>
<Input
label="Tags (optional)"
placeholder="spanisch, grundlagen, vokabeln"
value={tags}
onChangeText={setTags}
leftIcon="pricetags-outline"
autoCapitalize="none"
/>
</Card>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<View style={{ marginBottom: spacing.sm, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ marginRight: spacing.content.small, flex: 1 }}>
<Text style={{ fontSize: 16, fontWeight: '500', color: colors.foreground }}>Öffentliches Deck</Text>
<Text style={{ marginTop: spacing.xs, fontSize: 14, color: colors.mutedForeground }}>
Andere Nutzer können dein Deck sehen und lernen
</Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: '#CBD5E1', true: '#93C5FD' }}
thumbColor={isPublic ? '#3B82F6' : '#F3F4F6'}
/>
</View>
</Card>
<Card padding="lg" variant="elevated" style={{ marginBottom: spacing.lg }}>
<View
style={{
marginBottom: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ marginRight: spacing.content.small, flex: 1 }}>
<Text style={{ fontSize: 16, fontWeight: '500', color: colors.foreground }}>
Öffentliches Deck
</Text>
<Text
style={{
marginTop: spacing.xs,
fontSize: 14,
color: colors.mutedForeground,
}}
>
Andere Nutzer können dein Deck sehen und lernen
</Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: '#CBD5E1', true: '#93C5FD' }}
thumbColor={isPublic ? '#3B82F6' : '#F3F4F6'}
/>
</View>
</Card>
<Button
onPress={handleCreate}
loading={isLoading}
fullWidth
size="lg"
leftIcon={
<Icon name="add-circle-outline" size={20} color="white" library="Ionicons" />
}>
Deck erstellen
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
</View>
</>
);
<Button
onPress={handleCreate}
loading={isLoading}
fullWidth
size="lg"
leftIcon={
<Icon name="add-circle-outline" size={20} color="white" library="Ionicons" />
}
>
Deck erstellen
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
</View>
</>
);
}

View file

@ -1,5 +1,5 @@
import { Redirect } from 'expo-router';
export default function Index() {
return <Redirect href="/(tabs)" />;
return <Redirect href="/(tabs)" />;
}

View file

@ -4,10 +4,10 @@ import { Platform } from 'react-native';
import { ScreenContent } from '~/components/ScreenContent';
export default function Modal() {
return (
<>
<ScreenContent path="app/modal.tsx" title="Modal"></ScreenContent>
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</>
);
return (
<>
<ScreenContent path="app/modal.tsx" title="Modal"></ScreenContent>
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</>
);
}

View file

@ -13,309 +13,373 @@ import { QuizContent } from '../../../store/cardStore';
import { useThemeColors } from '../../../utils/themeUtils';
export default function StudySessionScreen() {
const { id: deckId } = useLocalSearchParams<{ id: string }>();
const {
currentSession,
sessionCards,
currentCardIndex,
isFlipped,
selectedAnswer,
showFeedback,
startSession,
endSession,
nextCard,
flipCard,
selectAnswer,
submitAnswer,
isLoading,
} = useStudyStore();
const { fetchDeck, currentDeck } = useDeckStore();
const colors = useThemeColors();
const { id: deckId } = useLocalSearchParams<{ id: string }>();
const {
currentSession,
sessionCards,
currentCardIndex,
isFlipped,
selectedAnswer,
showFeedback,
startSession,
endSession,
nextCard,
flipCard,
selectAnswer,
submitAnswer,
isLoading,
} = useStudyStore();
const { fetchDeck, currentDeck } = useDeckStore();
const colors = useThemeColors();
const [sessionStarted, setSessionStarted] = useState(false);
const [sessionStarted, setSessionStarted] = useState(false);
useEffect(() => {
if (deckId && !sessionStarted) {
// Fetch deck info
fetchDeck(deckId);
// Start study session
startSession(deckId, 'all');
setSessionStarted(true);
}
}, [deckId]);
useEffect(() => {
if (deckId && !sessionStarted) {
// Fetch deck info
fetchDeck(deckId);
// Start study session
startSession(deckId, 'all');
setSessionStarted(true);
}
}, [deckId]);
const handleEndSession = () => {
Alert.alert('Session beenden', 'Möchtest du die Lernsession wirklich beenden?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Beenden',
style: 'destructive',
onPress: async () => {
await endSession();
router.push(`/study/summary/${deckId}`);
},
},
]);
};
const handleEndSession = () => {
Alert.alert('Session beenden', 'Möchtest du die Lernsession wirklich beenden?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Beenden',
style: 'destructive',
onPress: async () => {
await endSession();
router.push(`/study/summary/${deckId}`);
},
},
]);
};
const handleQuizSubmit = () => {
if (selectedAnswer === null) return;
const handleQuizSubmit = () => {
if (selectedAnswer === null) return;
const currentCard = sessionCards[currentCardIndex];
const quizContent = currentCard.content as QuizContent;
const isCorrect = selectedAnswer === quizContent.correct_answer;
const currentCard = sessionCards[currentCardIndex];
const quizContent = currentCard.content as QuizContent;
const isCorrect = selectedAnswer === quizContent.correct_answer;
submitAnswer(isCorrect);
};
submitAnswer(isCorrect);
};
const handleFlashcardRate = (difficulty: 'easy' | 'medium' | 'hard' | 'again') => {
// For flashcards, we consider medium and easy as correct, hard and again as incorrect
const isCorrect = difficulty !== 'hard' && difficulty !== 'again';
submitAnswer(isCorrect, difficulty);
};
const handleFlashcardRate = (difficulty: 'easy' | 'medium' | 'hard' | 'again') => {
// For flashcards, we consider medium and easy as correct, hard and again as incorrect
const isCorrect = difficulty !== 'hard' && difficulty !== 'again';
submitAnswer(isCorrect, difficulty);
};
const handleTextCardNext = () => {
// Text cards are always "correct" as they're just for reading
submitAnswer(true);
if (currentCardIndex < sessionCards.length - 1) {
nextCard();
} else {
router.push(`/study/summary/${deckId}`);
}
};
const handleTextCardNext = () => {
// Text cards are always "correct" as they're just for reading
submitAnswer(true);
if (currentCardIndex < sessionCards.length - 1) {
nextCard();
} else {
router.push(`/study/summary/${deckId}`);
}
};
if (isLoading || !currentSession || sessionCards.length === 0) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
<Text style={{ color: colors.mutedForeground }}>Lernsession wird vorbereitet...</Text>
</View>
);
}
if (isLoading || !currentSession || sessionCards.length === 0) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
}}
>
<Text style={{ color: colors.mutedForeground }}>Lernsession wird vorbereitet...</Text>
</View>
);
}
const currentCard = sessionCards[currentCardIndex];
const progress = ((currentCardIndex + 1) / sessionCards.length) * 100;
const isLastCard = currentCardIndex === sessionCards.length - 1;
const currentCard = sessionCards[currentCardIndex];
const progress = ((currentCardIndex + 1) / sessionCards.length) * 100;
const isLastCard = currentCardIndex === sessionCards.length - 1;
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: currentDeck?.title || 'Lernsession',
headerLeft: () => (
<Pressable
onPress={handleEndSession}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon
name="close"
size={24}
color={colors.foreground}
style={{ marginLeft: 10 }}
library="Ionicons"
/>
</Pressable>
),
headerRight: () => (
<View style={{ marginRight: 16, flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontWeight: '500', color: colors.foreground }}>
{currentCardIndex + 1}/{sessionCards.length}
</Text>
</View>
),
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
{/* Progress Bar */}
<View style={{ height: 8, backgroundColor: colors.muted }}>
<View style={{ height: '100%', backgroundColor: colors.primary, width: `${progress}%` }} />
</View>
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: currentDeck?.title || 'Lernsession',
headerLeft: () => (
<Pressable
onPress={handleEndSession}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon
name="close"
size={24}
color={colors.foreground}
style={{ marginLeft: 10 }}
library="Ionicons"
/>
</Pressable>
),
headerRight: () => (
<View style={{ marginRight: 16, flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontWeight: '500', color: colors.foreground }}>
{currentCardIndex + 1}/{sessionCards.length}
</Text>
</View>
),
}}
/>
<View style={{ flex: 1, backgroundColor: colors.background }}>
{/* Progress Bar */}
<View style={{ height: 8, backgroundColor: colors.muted }}>
<View
style={{ height: '100%', backgroundColor: colors.primary, width: `${progress}%` }}
/>
</View>
{/* Stats Bar */}
<View style={{ borderBottomWidth: 1, borderBottomColor: colors.border, backgroundColor: colors.card, paddingHorizontal: 16, paddingVertical: 12 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-around' }}>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: 'bold', color: colors.foreground }}>
{currentSession.completed_cards}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Beantwortet
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: 'bold', color: colors.primary }}>
{currentSession.correct_answers}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Richtig
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: 'bold', color: colors.foreground }}>
{Math.round(
currentSession.completed_cards > 0
? (currentSession.correct_answers / currentSession.completed_cards) * 100
: 0
)}
%
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Quote
</Text>
</View>
</View>
</View>
{/* Stats Bar */}
<View
style={{
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.card,
paddingHorizontal: 16,
paddingVertical: 12,
}}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-around' }}>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: 'bold', color: colors.foreground }}>
{currentSession.completed_cards}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Beantwortet
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: 'bold', color: colors.primary }}>
{currentSession.correct_answers}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Richtig
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: 'bold', color: colors.foreground }}>
{Math.round(
currentSession.completed_cards > 0
? (currentSession.correct_answers / currentSession.completed_cards) * 100
: 0
)}
%
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Quote
</Text>
</View>
</View>
</View>
{/* Card Display - Fixed position */}
<View style={{ flex: 1, paddingHorizontal: 16 }}>
{/* Card Container - always same aspect ratio (3:4) portrait */}
<View style={{ width: '100%', aspectRatio: 3/4, marginTop: 24 }}>
{currentCard.card_type === 'flashcard' ? (
<Pressable onPress={flipCard} style={({ pressed }) => ({ opacity: pressed ? 0.9 : 1, height: '100%', width: '100%' })}>
<CardView card={currentCard} mode="study" isFlipped={isFlipped} onFlip={flipCard} />
</Pressable>
) : currentCard.card_type === 'quiz' ? (
<CardView
card={currentCard}
mode="study"
selectedAnswer={selectedAnswer}
showFeedback={showFeedback}
onAnswerSelect={selectAnswer}
/>
) : (
<CardView card={currentCard} mode="study" />
)}
</View>
{/* Card Display - Fixed position */}
<View style={{ flex: 1, paddingHorizontal: 16 }}>
{/* Card Container - always same aspect ratio (3:4) portrait */}
<View style={{ width: '100%', aspectRatio: 3 / 4, marginTop: 24 }}>
{currentCard.card_type === 'flashcard' ? (
<Pressable
onPress={flipCard}
style={({ pressed }) => ({
opacity: pressed ? 0.9 : 1,
height: '100%',
width: '100%',
})}
>
<CardView card={currentCard} mode="study" isFlipped={isFlipped} onFlip={flipCard} />
</Pressable>
) : currentCard.card_type === 'quiz' ? (
<CardView
card={currentCard}
mode="study"
selectedAnswer={selectedAnswer}
showFeedback={showFeedback}
onAnswerSelect={selectAnswer}
/>
) : (
<CardView card={currentCard} mode="study" />
)}
</View>
{/* Spacer to push buttons to bottom */}
<View style={{ flex: 1 }} />
{/* Spacer to push buttons to bottom */}
<View style={{ flex: 1 }} />
{/* Action Buttons - Fixed at bottom */}
<View style={{ paddingBottom: spacing.container.bottom }}>
{currentCard.card_type === 'flashcard' && (
<>
{!isFlipped ? (
<Button
onPress={flipCard}
variant="primary"
fullWidth
size="lg"
leftIcon={
<Icon name="refresh-outline" size={24} color="white" library="Ionicons" />
}>
Antwort zeigen
</Button>
) : !showFeedback ? (
<View>
<Text variant="caption" style={{ marginBottom: spacing.content.small, textAlign: 'center', color: colors.mutedForeground }}>
Wie gut konntest du dich erinnern?
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}>
<Button
onPress={() => handleFlashcardRate('again')}
variant="danger"
fullWidth
size="sm">
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontWeight: 'bold', color: 'white' }}>Nochmal</Text>
<Text style={{ fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>&lt; 1 min</Text>
</View>
</Button>
</View>
<View style={{ flex: 1 }}>
<Button
onPress={() => handleFlashcardRate('hard')}
variant="outline"
fullWidth
size="sm">
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontWeight: 'bold', color: colors.foreground }}>Schwer</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>~6 min</Text>
</View>
</Button>
</View>
<View style={{ flex: 1 }}>
<Button
onPress={() => handleFlashcardRate('medium')}
variant="secondary"
fullWidth
size="sm">
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontWeight: 'bold', color: colors.foreground }}>Gut</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>~10 min</Text>
</View>
</Button>
</View>
<View style={{ flex: 1 }}>
<Button
onPress={() => handleFlashcardRate('easy')}
variant="primary"
fullWidth
size="sm">
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontWeight: 'bold', color: 'white' }}>Leicht</Text>
<Text style={{ fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>~4 Tage</Text>
</View>
</Button>
</View>
</View>
</View>
) : (
<Button
onPress={isLastCard ? () => router.push(`/study/summary/${deckId}`) : nextCard}
variant="primary"
fullWidth
size="lg">
{isLastCard ? 'Session beenden' : 'Nächste Karte'}
</Button>
)}
</>
)}
{/* Action Buttons - Fixed at bottom */}
<View style={{ paddingBottom: spacing.container.bottom }}>
{currentCard.card_type === 'flashcard' && (
<>
{!isFlipped ? (
<Button
onPress={flipCard}
variant="primary"
fullWidth
size="lg"
leftIcon={
<Icon name="refresh-outline" size={24} color="white" library="Ionicons" />
}
>
Antwort zeigen
</Button>
) : !showFeedback ? (
<View>
<Text
variant="caption"
style={{
marginBottom: spacing.content.small,
textAlign: 'center',
color: colors.mutedForeground,
}}
>
Wie gut konntest du dich erinnern?
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}>
<Button
onPress={() => handleFlashcardRate('again')}
variant="danger"
fullWidth
size="sm"
>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontWeight: 'bold', color: 'white' }}>
Nochmal
</Text>
<Text style={{ fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
&lt; 1 min
</Text>
</View>
</Button>
</View>
<View style={{ flex: 1 }}>
<Button
onPress={() => handleFlashcardRate('hard')}
variant="outline"
fullWidth
size="sm"
>
<View style={{ alignItems: 'center' }}>
<Text
style={{
fontSize: 12,
fontWeight: 'bold',
color: colors.foreground,
}}
>
Schwer
</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
~6 min
</Text>
</View>
</Button>
</View>
<View style={{ flex: 1 }}>
<Button
onPress={() => handleFlashcardRate('medium')}
variant="secondary"
fullWidth
size="sm"
>
<View style={{ alignItems: 'center' }}>
<Text
style={{
fontSize: 12,
fontWeight: 'bold',
color: colors.foreground,
}}
>
Gut
</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
~10 min
</Text>
</View>
</Button>
</View>
<View style={{ flex: 1 }}>
<Button
onPress={() => handleFlashcardRate('easy')}
variant="primary"
fullWidth
size="sm"
>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontWeight: 'bold', color: 'white' }}>
Leicht
</Text>
<Text style={{ fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
~4 Tage
</Text>
</View>
</Button>
</View>
</View>
</View>
) : (
<Button
onPress={isLastCard ? () => router.push(`/study/summary/${deckId}`) : nextCard}
variant="primary"
fullWidth
size="lg"
>
{isLastCard ? 'Session beenden' : 'Nächste Karte'}
</Button>
)}
</>
)}
{currentCard.card_type === 'quiz' && (
<>
{!showFeedback ? (
<Button
onPress={handleQuizSubmit}
variant="primary"
fullWidth
size="lg"
disabled={selectedAnswer === null}>
Antwort überprüfen
</Button>
) : (
<Button
onPress={isLastCard ? () => router.push(`/study/summary/${deckId}`) : nextCard}
variant="primary"
fullWidth
size="lg">
{isLastCard ? 'Session beenden' : 'Nächste Karte'}
</Button>
)}
</>
)}
{currentCard.card_type === 'quiz' && (
<>
{!showFeedback ? (
<Button
onPress={handleQuizSubmit}
variant="primary"
fullWidth
size="lg"
disabled={selectedAnswer === null}
>
Antwort überprüfen
</Button>
) : (
<Button
onPress={isLastCard ? () => router.push(`/study/summary/${deckId}`) : nextCard}
variant="primary"
fullWidth
size="lg"
>
{isLastCard ? 'Session beenden' : 'Nächste Karte'}
</Button>
)}
</>
)}
{currentCard.card_type === 'text' && (
<Button onPress={handleTextCardNext} variant="primary" fullWidth size="lg">
{isLastCard ? 'Session beenden' : 'Verstanden, weiter'}
</Button>
)}
</View>
{currentCard.card_type === 'text' && (
<Button onPress={handleTextCardNext} variant="primary" fullWidth size="lg">
{isLastCard ? 'Session beenden' : 'Verstanden, weiter'}
</Button>
)}
</View>
{/* Skip Button */}
{!showFeedback && !isLastCard && (
<View style={{ marginTop: spacing.content.small }}>
<Button
onPress={nextCard}
variant="ghost"
fullWidth
size="md">
Überspringen
</Button>
</View>
)}
</View>
</View>
</>
);
{/* Skip Button */}
{!showFeedback && !isLastCard && (
<View style={{ marginTop: spacing.content.small }}>
<Button onPress={nextCard} variant="ghost" fullWidth size="md">
Überspringen
</Button>
</View>
)}
</View>
</View>
</>
);
}

View file

@ -9,202 +9,200 @@ import { Button } from '../../../components/ui/Button';
import { Card } from '../../../components/ui/Card';
export default function StudySummaryScreen() {
const { id: deckId } = useLocalSearchParams<{ id: string }>();
const { currentSession, cardProgress, resetState } = useStudyStore();
const { currentDeck, fetchDeck } = useDeckStore();
const { id: deckId } = useLocalSearchParams<{ id: string }>();
const { currentSession, cardProgress, resetState } = useStudyStore();
const { currentDeck, fetchDeck } = useDeckStore();
const [sessionData, setSessionData] = useState<{
totalCards: number;
completedCards: number;
correctAnswers: number;
incorrectAnswers: number;
accuracy: number;
duration: string;
} | null>(null);
const [sessionData, setSessionData] = useState<{
totalCards: number;
completedCards: number;
correctAnswers: number;
incorrectAnswers: number;
accuracy: number;
duration: string;
} | null>(null);
useEffect(() => {
if (deckId) {
fetchDeck(deckId);
}
useEffect(() => {
if (deckId) {
fetchDeck(deckId);
}
// Calculate session stats
if (currentSession) {
const startTime = new Date(currentSession.started_at);
const endTime = currentSession.ended_at ? new Date(currentSession.ended_at) : new Date();
const durationMs = endTime.getTime() - startTime.getTime();
const minutes = Math.floor(durationMs / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
// Calculate session stats
if (currentSession) {
const startTime = new Date(currentSession.started_at);
const endTime = currentSession.ended_at ? new Date(currentSession.ended_at) : new Date();
const durationMs = endTime.getTime() - startTime.getTime();
const minutes = Math.floor(durationMs / 60000);
const seconds = Math.floor((durationMs % 60000) / 1000);
setSessionData({
totalCards: currentSession.total_cards,
completedCards: currentSession.completed_cards,
correctAnswers: currentSession.correct_answers,
incorrectAnswers: currentSession.completed_cards - currentSession.correct_answers,
accuracy:
currentSession.completed_cards > 0
? Math.round((currentSession.correct_answers / currentSession.completed_cards) * 100)
: 0,
duration: `${minutes}:${seconds.toString().padStart(2, '0')}`,
});
}
}, [currentSession, deckId]);
setSessionData({
totalCards: currentSession.total_cards,
completedCards: currentSession.completed_cards,
correctAnswers: currentSession.correct_answers,
incorrectAnswers: currentSession.completed_cards - currentSession.correct_answers,
accuracy:
currentSession.completed_cards > 0
? Math.round((currentSession.correct_answers / currentSession.completed_cards) * 100)
: 0,
duration: `${minutes}:${seconds.toString().padStart(2, '0')}`,
});
}
}, [currentSession, deckId]);
const getPerformanceEmoji = (accuracy: number) => {
if (accuracy >= 90) return '🎉';
if (accuracy >= 75) return '💪';
if (accuracy >= 60) return '👍';
if (accuracy >= 40) return '🤔';
return '📚';
};
const getPerformanceEmoji = (accuracy: number) => {
if (accuracy >= 90) return '🎉';
if (accuracy >= 75) return '💪';
if (accuracy >= 60) return '👍';
if (accuracy >= 40) return '🤔';
return '📚';
};
const getPerformanceMessage = (accuracy: number) => {
if (accuracy >= 90) return 'Hervorragend! Du beherrschst das Material sehr gut!';
if (accuracy >= 75) return 'Sehr gut! Du machst tolle Fortschritte!';
if (accuracy >= 60) return 'Gut gemacht! Weiter so!';
if (accuracy >= 40) return 'Nicht schlecht! Mit mehr Übung wird es besser!';
return 'Übung macht den Meister! Bleib dran!';
};
const getPerformanceMessage = (accuracy: number) => {
if (accuracy >= 90) return 'Hervorragend! Du beherrschst das Material sehr gut!';
if (accuracy >= 75) return 'Sehr gut! Du machst tolle Fortschritte!';
if (accuracy >= 60) return 'Gut gemacht! Weiter so!';
if (accuracy >= 40) return 'Nicht schlecht! Mit mehr Übung wird es besser!';
return 'Übung macht den Meister! Bleib dran!';
};
const handleNewSession = () => {
resetState();
router.replace(`/study/session/${deckId}`);
};
const handleNewSession = () => {
resetState();
router.replace(`/study/session/${deckId}`);
};
const handleBackToDeck = () => {
resetState();
router.replace(`/deck/${deckId}`);
};
const handleBackToDeck = () => {
resetState();
router.replace(`/deck/${deckId}`);
};
if (!sessionData) {
return (
<View className="flex-1 items-center justify-center bg-gray-100">
<Text className="text-gray-500">Zusammenfassung wird geladen...</Text>
</View>
);
}
if (!sessionData) {
return (
<View className="flex-1 items-center justify-center bg-gray-100">
<Text className="text-gray-500">Zusammenfassung wird geladen...</Text>
</View>
);
}
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: 'Lernsession abgeschlossen',
headerLeft: () => null,
}}
/>
<View className="flex-1 bg-gray-100">
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
<View className="px-4 py-6">
{/* Success Header */}
<Card padding="lg" variant="elevated" className="mb-6">
<View className="items-center">
<Text className="mb-3 text-6xl">{getPerformanceEmoji(sessionData.accuracy)}</Text>
<Text className="mb-2 text-2xl font-bold text-gray-900">
Session abgeschlossen!
</Text>
<Text className="text-center text-gray-600">
{getPerformanceMessage(sessionData.accuracy)}
</Text>
</View>
</Card>
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: 'Lernsession abgeschlossen',
headerLeft: () => null,
}}
/>
<View className="flex-1 bg-gray-100">
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
<View className="px-4 py-6">
{/* Success Header */}
<Card padding="lg" variant="elevated" className="mb-6">
<View className="items-center">
<Text className="mb-3 text-6xl">{getPerformanceEmoji(sessionData.accuracy)}</Text>
<Text className="mb-2 text-2xl font-bold text-gray-900">
Session abgeschlossen!
</Text>
<Text className="text-center text-gray-600">
{getPerformanceMessage(sessionData.accuracy)}
</Text>
</View>
</Card>
{/* Stats Overview */}
<Card padding="lg" variant="elevated" className="mb-6">
<Text className="mb-4 text-lg font-semibold text-gray-900">Deine Statistiken</Text>
{/* Stats Overview */}
<Card padding="lg" variant="elevated" className="mb-6">
<Text className="mb-4 text-lg font-semibold text-gray-900">Deine Statistiken</Text>
{/* Accuracy Circle */}
<View className="mb-6 items-center">
<View className="relative h-32 w-32 items-center justify-center rounded-full border-8 border-blue-100">
<Text className="text-3xl font-bold text-blue-600">{sessionData.accuracy}%</Text>
<Text className="text-xs text-gray-500">Genauigkeit</Text>
</View>
</View>
{/* Accuracy Circle */}
<View className="mb-6 items-center">
<View className="relative h-32 w-32 items-center justify-center rounded-full border-8 border-blue-100">
<Text className="text-3xl font-bold text-blue-600">{sessionData.accuracy}%</Text>
<Text className="text-xs text-gray-500">Genauigkeit</Text>
</View>
</View>
{/* Detailed Stats */}
<View className="space-y-3">
<View className="flex-row items-center justify-between rounded-lg bg-gray-50 p-3">
<View className="flex-row items-center">
<Ionicons name="time-outline" size={20} color="#6B7280" />
<Text className="ml-2 text-gray-700">Dauer</Text>
</View>
<Text className="font-medium text-gray-900">{sessionData.duration}</Text>
</View>
{/* Detailed Stats */}
<View className="space-y-3">
<View className="flex-row items-center justify-between rounded-lg bg-gray-50 p-3">
<View className="flex-row items-center">
<Ionicons name="time-outline" size={20} color="#6B7280" />
<Text className="ml-2 text-gray-700">Dauer</Text>
</View>
<Text className="font-medium text-gray-900">{sessionData.duration}</Text>
</View>
<View className="flex-row items-center justify-between rounded-lg bg-gray-50 p-3">
<View className="flex-row items-center">
<Ionicons name="card-outline" size={20} color="#6B7280" />
<Text className="ml-2 text-gray-700">Bearbeitete Karten</Text>
</View>
<Text className="font-medium text-gray-900">
{sessionData.completedCards} / {sessionData.totalCards}
</Text>
</View>
<View className="flex-row items-center justify-between rounded-lg bg-gray-50 p-3">
<View className="flex-row items-center">
<Ionicons name="card-outline" size={20} color="#6B7280" />
<Text className="ml-2 text-gray-700">Bearbeitete Karten</Text>
</View>
<Text className="font-medium text-gray-900">
{sessionData.completedCards} / {sessionData.totalCards}
</Text>
</View>
<View className="flex-row items-center justify-between rounded-lg bg-green-50 p-3">
<View className="flex-row items-center">
<Ionicons name="checkmark-circle" size={20} color="#10B981" />
<Text className="ml-2 text-gray-700">Richtige Antworten</Text>
</View>
<Text className="font-medium text-green-600">{sessionData.correctAnswers}</Text>
</View>
<View className="flex-row items-center justify-between rounded-lg bg-green-50 p-3">
<View className="flex-row items-center">
<Ionicons name="checkmark-circle" size={20} color="#10B981" />
<Text className="ml-2 text-gray-700">Richtige Antworten</Text>
</View>
<Text className="font-medium text-green-600">{sessionData.correctAnswers}</Text>
</View>
<View className="flex-row items-center justify-between rounded-lg bg-red-50 p-3">
<View className="flex-row items-center">
<Ionicons name="close-circle" size={20} color="#EF4444" />
<Text className="ml-2 text-gray-700">Falsche Antworten</Text>
</View>
<Text className="font-medium text-red-600">{sessionData.incorrectAnswers}</Text>
</View>
</View>
</Card>
<View className="flex-row items-center justify-between rounded-lg bg-red-50 p-3">
<View className="flex-row items-center">
<Ionicons name="close-circle" size={20} color="#EF4444" />
<Text className="ml-2 text-gray-700">Falsche Antworten</Text>
</View>
<Text className="font-medium text-red-600">{sessionData.incorrectAnswers}</Text>
</View>
</View>
</Card>
{/* Progress Tracking */}
<Card padding="lg" variant="outlined" className="mb-6">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-sm text-gray-500">Lernstreak</Text>
<Text className="text-2xl font-bold text-gray-900">1 Tag 🔥</Text>
</View>
<View>
<Text className="text-sm text-gray-500">Gesamtfortschritt</Text>
<Text className="text-2xl font-bold text-gray-900">
{sessionData.completedCards} Karten
</Text>
</View>
</View>
</Card>
{/* Progress Tracking */}
<Card padding="lg" variant="outlined" className="mb-6">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-sm text-gray-500">Lernstreak</Text>
<Text className="text-2xl font-bold text-gray-900">1 Tag 🔥</Text>
</View>
<View>
<Text className="text-sm text-gray-500">Gesamtfortschritt</Text>
<Text className="text-2xl font-bold text-gray-900">
{sessionData.completedCards} Karten
</Text>
</View>
</View>
</Card>
{/* Action Buttons */}
<View className="space-y-3">
<Button
onPress={handleNewSession}
variant="primary"
fullWidth
size="lg"
leftIcon={<Ionicons name="refresh-outline" size={20} color="white" />}>
Neue Session starten
</Button>
{/* Action Buttons */}
<View className="space-y-3">
<Button
onPress={handleNewSession}
variant="primary"
fullWidth
size="lg"
leftIcon={<Ionicons name="refresh-outline" size={20} color="white" />}
>
Neue Session starten
</Button>
<Button
onPress={handleBackToDeck}
variant="outline"
fullWidth
size="lg"
leftIcon={<Ionicons name="albums-outline" size={20} color="#374151" />}>
Zurück zum Deck
</Button>
<Button
onPress={handleBackToDeck}
variant="outline"
fullWidth
size="lg"
leftIcon={<Ionicons name="albums-outline" size={20} color="#374151" />}
>
Zurück zum Deck
</Button>
<Button
onPress={() => router.replace('/(tabs)')}
variant="ghost"
fullWidth
size="lg">
Zur Startseite
</Button>
</View>
</View>
</ScrollView>
</View>
</>
);
<Button onPress={() => router.replace('/(tabs)')} variant="ghost" fullWidth size="lg">
Zur Startseite
</Button>
</View>
</View>
</ScrollView>
</View>
</>
);
}

View file

@ -1,16 +1,16 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
['babel-preset-expo', {
jsxImportSource: 'nativewind',
unstable_transformImportMeta: true
}],
'nativewind/babel'
],
plugins: [
'expo-router/babel',
'react-native-reanimated/plugin'
],
};
api.cache(true);
return {
presets: [
[
'babel-preset-expo',
{
jsxImportSource: 'nativewind',
unstable_transformImportMeta: true,
},
],
'nativewind/babel',
],
plugins: ['expo-router/babel', 'react-native-reanimated/plugin'],
};
};

View file

@ -1,29 +1,29 @@
import { Text, View } from 'react-native';
export const EditScreenInfo = ({ path }: { path: string }) => {
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
};
const styles = {
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
};

View file

@ -3,76 +3,77 @@ import { View, Text, Pressable, ScrollView } from 'react-native';
import * as Updates from 'expo-updates';
interface Props {
children: ReactNode;
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
this.setState({ error, errorInfo });
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
this.setState({ error, errorInfo });
}
handleRestart = async () => {
if (Updates.isEnabled) {
await Updates.reloadAsync();
} else {
// In development, just reset the error state
this.setState({ hasError: false, error: null, errorInfo: null });
}
};
handleRestart = async () => {
if (Updates.isEnabled) {
await Updates.reloadAsync();
} else {
// In development, just reset the error state
this.setState({ hasError: false, error: null, errorInfo: null });
}
};
render() {
if (this.state.hasError) {
return (
<View className="flex-1 bg-background p-6">
<ScrollView>
<Text className="mb-4 text-2xl font-bold text-destructive">
Ein Fehler ist aufgetreten
</Text>
render() {
if (this.state.hasError) {
return (
<View className="flex-1 bg-background p-6">
<ScrollView>
<Text className="mb-4 text-2xl font-bold text-destructive">
Ein Fehler ist aufgetreten
</Text>
<View className="mb-4 rounded-lg bg-destructive/10 p-4">
<Text className="text-sm font-semibold text-destructive">
{this.state.error?.message || 'Unbekannter Fehler'}
</Text>
</View>
<View className="mb-4 rounded-lg bg-destructive/10 p-4">
<Text className="text-sm font-semibold text-destructive">
{this.state.error?.message || 'Unbekannter Fehler'}
</Text>
</View>
{__DEV__ && this.state.errorInfo && (
<View className="mb-4 rounded-lg bg-muted p-4">
<Text className="mb-2 text-sm font-semibold text-foreground">Stack Trace:</Text>
<Text className="font-mono text-xs text-muted-foreground">
{this.state.errorInfo.componentStack}
</Text>
</View>
)}
{__DEV__ && this.state.errorInfo && (
<View className="mb-4 rounded-lg bg-muted p-4">
<Text className="mb-2 text-sm font-semibold text-foreground">Stack Trace:</Text>
<Text className="font-mono text-xs text-muted-foreground">
{this.state.errorInfo.componentStack}
</Text>
</View>
)}
<Pressable
onPress={this.handleRestart}
className="rounded-lg bg-primary px-4 py-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text className="text-center font-semibold text-primary-foreground">
App neu starten
</Text>
</Pressable>
</ScrollView>
</View>
);
}
<Pressable
onPress={this.handleRestart}
className="rounded-lg bg-primary px-4 py-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Text className="text-center font-semibold text-primary-foreground">
App neu starten
</Text>
</Pressable>
</ScrollView>
</View>
);
}
return this.props.children;
}
return this.props.children;
}
}

View file

@ -3,31 +3,31 @@ import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Pressable, StyleSheet } from 'react-native';
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
({ onPress }, ref) => {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color="gray"
style={[
styles.headerRight,
{
opacity: pressed ? 0.5 : 1,
},
]}
/>
)}
</Pressable>
);
}
({ onPress }, ref) => {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color="gray"
style={[
styles.headerRight,
{
opacity: pressed ? 0.5 : 1,
},
]}
/>
)}
</Pressable>
);
}
);
HeaderButton.displayName = 'HeaderButton';
export const styles = StyleSheet.create({
headerRight: {
marginRight: 15,
},
headerRight: {
marginRight: 15,
},
});

View file

@ -2,190 +2,175 @@ import React from 'react';
import { View, Text, Modal, TouchableOpacity, StyleSheet } from 'react-native';
interface InsufficientCreditsModalProps {
visible: boolean;
requiredCredits: number;
availableCredits: number;
operation?: string;
onClose: () => void;
onPurchase?: () => void;
visible: boolean;
requiredCredits: number;
availableCredits: number;
operation?: string;
onClose: () => void;
onPurchase?: () => void;
}
/**
* Modal shown when user has insufficient mana credits
*/
export function InsufficientCreditsModal({
visible,
requiredCredits,
availableCredits,
operation = 'this operation',
onClose,
onPurchase,
visible,
requiredCredits,
availableCredits,
operation = 'this operation',
onClose,
onPurchase,
}: InsufficientCreditsModalProps) {
const shortfall = requiredCredits - availableCredits;
const shortfall = requiredCredits - availableCredits;
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<View style={styles.modalContainer}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.icon}></Text>
<Text style={styles.title}>Insufficient Mana</Text>
</View>
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={styles.overlay}>
<View style={styles.modalContainer}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.icon}></Text>
<Text style={styles.title}>Insufficient Mana</Text>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.message}>
You don't have enough mana to {operation}.
</Text>
{/* Content */}
<View style={styles.content}>
<Text style={styles.message}>You don't have enough mana to {operation}.</Text>
<View style={styles.creditsInfo}>
<View style={styles.creditsRow}>
<Text style={styles.label}>Required:</Text>
<Text style={styles.value}>{requiredCredits} mana</Text>
</View>
<View style={styles.creditsRow}>
<Text style={styles.label}>Available:</Text>
<Text style={styles.value}>{availableCredits} mana</Text>
</View>
<View style={[styles.creditsRow, styles.shortfallRow]}>
<Text style={styles.label}>Needed:</Text>
<Text style={[styles.value, styles.shortfall]}>
{shortfall} mana
</Text>
</View>
</View>
</View>
<View style={styles.creditsInfo}>
<View style={styles.creditsRow}>
<Text style={styles.label}>Required:</Text>
<Text style={styles.value}>{requiredCredits} mana</Text>
</View>
<View style={styles.creditsRow}>
<Text style={styles.label}>Available:</Text>
<Text style={styles.value}>{availableCredits} mana</Text>
</View>
<View style={[styles.creditsRow, styles.shortfallRow]}>
<Text style={styles.label}>Needed:</Text>
<Text style={[styles.value, styles.shortfall]}>{shortfall} mana</Text>
</View>
</View>
</View>
{/* Actions */}
<View style={styles.actions}>
{onPurchase && (
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={onPurchase}
>
<Text style={styles.primaryButtonText}>Get More Mana</Text>
</TouchableOpacity>
)}
{/* Actions */}
<View style={styles.actions}>
{onPurchase && (
<TouchableOpacity style={[styles.button, styles.primaryButton]} onPress={onPurchase}>
<Text style={styles.primaryButtonText}>Get More Mana</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={onClose}
>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
<TouchableOpacity style={[styles.button, styles.secondaryButton]} onPress={onClose}>
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContainer: {
backgroundColor: 'white',
borderRadius: 16,
width: '100%',
maxWidth: 400,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 5,
},
header: {
alignItems: 'center',
paddingTop: 24,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
icon: {
fontSize: 48,
marginBottom: 8,
},
title: {
fontSize: 22,
fontWeight: '600',
color: '#1a1a1a',
},
content: {
padding: 24,
},
message: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 20,
lineHeight: 24,
},
creditsInfo: {
backgroundColor: '#f8f9fa',
borderRadius: 12,
padding: 16,
},
creditsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
},
shortfallRow: {
marginBottom: 0,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
label: {
fontSize: 15,
color: '#666',
fontWeight: '500',
},
value: {
fontSize: 15,
color: '#1a1a1a',
fontWeight: '600',
},
shortfall: {
color: '#dc2626',
},
actions: {
padding: 16,
gap: 12,
},
button: {
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 12,
alignItems: 'center',
},
primaryButton: {
backgroundColor: '#3b82f6',
},
primaryButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#d1d5db',
},
secondaryButtonText: {
color: '#6b7280',
fontSize: 16,
fontWeight: '500',
},
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContainer: {
backgroundColor: 'white',
borderRadius: 16,
width: '100%',
maxWidth: 400,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 5,
},
header: {
alignItems: 'center',
paddingTop: 24,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
icon: {
fontSize: 48,
marginBottom: 8,
},
title: {
fontSize: 22,
fontWeight: '600',
color: '#1a1a1a',
},
content: {
padding: 24,
},
message: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 20,
lineHeight: 24,
},
creditsInfo: {
backgroundColor: '#f8f9fa',
borderRadius: 12,
padding: 16,
},
creditsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
},
shortfallRow: {
marginBottom: 0,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
label: {
fontSize: 15,
color: '#666',
fontWeight: '500',
},
value: {
fontSize: 15,
color: '#1a1a1a',
fontWeight: '600',
},
shortfall: {
color: '#dc2626',
},
actions: {
padding: 16,
gap: 12,
},
button: {
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 12,
alignItems: 'center',
},
primaryButton: {
backgroundColor: '#3b82f6',
},
primaryButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#d1d5db',
},
secondaryButtonText: {
color: '#6b7280',
fontSize: 16,
fontWeight: '500',
},
});

View file

@ -3,23 +3,23 @@ import { Text, View } from 'react-native';
import { EditScreenInfo } from './EditScreenInfo';
type ScreenContentProps = {
title: string;
path: string;
children?: React.ReactNode;
title: string;
path: string;
children?: React.ReactNode;
};
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
return (
<View className={styles.container}>
<Text className={styles.title}>{title}</Text>
<View className={styles.separator} />
<EditScreenInfo path={path} />
{children}
</View>
);
return (
<View className={styles.container}>
<Text className={styles.title}>{title}</Text>
<View className={styles.separator} />
<EditScreenInfo path={path} />
{children}
</View>
);
};
const styles = {
container: `items-center flex-1 justify-center`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
container: `items-center flex-1 justify-center`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
};

View file

@ -1,13 +1,13 @@
import { Icon } from './ui/Icon';
export const TabBarIcon = (props: { name: string; color: string }) => {
return (
<Icon
name={props.name}
color={props.color}
size={28}
library="FontAwesome"
className="mb-[-3px]"
/>
);
return (
<Icon
name={props.name}
color={props.color}
size={28}
library="FontAwesome"
className="mb-[-3px]"
/>
);
};

View file

@ -3,25 +3,33 @@ import { ThemeStoreProvider, useThemeStore } from '~/store/themeStore';
import { ThemeContextType } from '~/types/theme';
interface ThemeProviderProps {
children: React.ReactNode;
children: React.ReactNode;
}
// This wrapper component provides the theme store context
export function ThemeProvider({ children }: ThemeProviderProps) {
return <ThemeStoreProvider>{children}</ThemeStoreProvider>;
return <ThemeStoreProvider>{children}</ThemeStoreProvider>;
}
// Export useTheme hook that wraps useThemeStore for backward compatibility
export function useTheme(): ThemeContextType {
const { theme, mode, isDark, currentColors: colors, setTheme, setMode, toggleMode } = useThemeStore();
const {
theme,
mode,
isDark,
currentColors: colors,
setTheme,
setMode,
toggleMode,
} = useThemeStore();
return {
theme,
mode,
isDark,
colors,
setTheme,
setMode,
toggleMode,
};
}
return {
theme,
mode,
isDark,
colors,
setTheme,
setMode,
toggleMode,
};
}

View file

@ -3,45 +3,45 @@ import { View, Platform } from 'react-native';
import { useRootClassName } from '~/store/themeStore';
interface ThemeWrapperProps {
children: React.ReactNode;
children: React.ReactNode;
}
export function ThemeWrapper({ children }: ThemeWrapperProps) {
const rootClassName = useRootClassName();
const [themeKey, setThemeKey] = useState(0);
const rootClassName = useRootClassName();
const [themeKey, setThemeKey] = useState(0);
useEffect(() => {
// For web, apply classes to document element
if (Platform.OS === 'web') {
const html = document.documentElement;
// Clear all existing theme classes
html.classList.remove('theme-default', 'theme-forest', 'theme-sunset', 'dark');
useEffect(() => {
// For web, apply classes to document element
if (Platform.OS === 'web') {
const html = document.documentElement;
// Clear all existing theme classes
html.classList.remove('theme-default', 'theme-forest', 'theme-sunset', 'dark');
// Apply new theme classes
if (rootClassName) {
const classes = rootClassName.split(' ').filter(Boolean);
classes.forEach((cls) => html.classList.add(cls));
}
}
// Apply new theme classes
if (rootClassName) {
const classes = rootClassName.split(' ').filter(Boolean);
classes.forEach((cls) => html.classList.add(cls));
}
}
// Force re-render to apply new CSS variables
setThemeKey((prev) => prev + 1);
// Force re-render to apply new CSS variables
setThemeKey((prev) => prev + 1);
// Force Metro/NativeWind to re-process styles
if (Platform.OS !== 'web') {
// Trigger a small delay to ensure styles are applied
setTimeout(() => {
setThemeKey((prev) => prev + 1);
}, 50);
}
}, [rootClassName]);
// Force Metro/NativeWind to re-process styles
if (Platform.OS !== 'web') {
// Trigger a small delay to ensure styles are applied
setTimeout(() => {
setThemeKey((prev) => prev + 1);
}, 50);
}
}, [rootClassName]);
// For React Native, we need to set the theme class on a wrapper View
// The CSS variables will be applied via the global CSS
// Using key prop to force re-render when theme changes
return (
<View key={themeKey} className={`flex-1 ${rootClassName}`} style={{ flex: 1 }}>
{children}
</View>
);
// For React Native, we need to set the theme class on a wrapper View
// The CSS variables will be applied via the global CSS
// Using key prop to force re-render when theme changes
return (
<View key={themeKey} className={`flex-1 ${rootClassName}`} style={{ flex: 1 }}>
{children}
</View>
);
}

View file

@ -9,186 +9,190 @@ import { useAIStore } from '../../store/aiStore';
import { GeneratedCard } from '../../utils/supabaseAIService';
interface ImageCardCreatorProps {
onCardsGenerated?: (cards: GeneratedCard[]) => void;
onCardsGenerated?: (cards: GeneratedCard[]) => void;
}
export const ImageCardCreator: React.FC<ImageCardCreatorProps> = ({ onCardsGenerated }) => {
const { generateCardsFromImage, isGenerating } = useAIStore();
const [imageUri, setImageUri] = useState<string | null>(null);
const [context, setContext] = useState('');
const [error, setError] = useState<string | null>(null);
const { generateCardsFromImage, isGenerating } = useAIStore();
const [imageUri, setImageUri] = useState<string | null>(null);
const [context, setContext] = useState('');
const [error, setError] = useState<string | null>(null);
const pickImage = async (source: 'camera' | 'library') => {
try {
let result;
const pickImage = async (source: 'camera' | 'library') => {
try {
let result;
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
setError('Kamerazugriff verweigert');
return;
}
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
setError('Kamerazugriff verweigert');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
base64: false,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
setError('Galerie-Zugriff verweigert');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
base64: false,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
setError('Galerie-Zugriff verweigert');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
base64: false,
});
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
quality: 0.8,
base64: false,
});
}
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setError(null);
}
} catch (error) {
console.error('Error picking image:', error);
setError('Fehler beim Auswählen des Bildes');
}
};
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setError(null);
}
} catch (error) {
console.error('Error picking image:', error);
setError('Fehler beim Auswählen des Bildes');
}
};
const handleGenerateCards = async () => {
if (!imageUri) return;
const handleGenerateCards = async () => {
if (!imageUri) return;
try {
setError(null);
const cards = await generateCardsFromImage(imageUri, context);
try {
setError(null);
const cards = await generateCardsFromImage(imageUri, context);
if (onCardsGenerated) {
onCardsGenerated(cards);
}
if (onCardsGenerated) {
onCardsGenerated(cards);
}
// Reset after successful generation
setImageUri(null);
setContext('');
} catch (error: any) {
setError(error.message || 'Fehler beim Generieren der Karten');
}
};
// Reset after successful generation
setImageUri(null);
setContext('');
} catch (error: any) {
setError(error.message || 'Fehler beim Generieren der Karten');
}
};
const removeImage = () => {
setImageUri(null);
setContext('');
setError(null);
};
const removeImage = () => {
setImageUri(null);
setContext('');
setError(null);
};
return (
<Card padding="lg" variant="elevated">
{!imageUri ? (
<>
<Text variant="h4" className="mb-4 text-center font-semibold text-gray-900">
Bild auswählen
</Text>
return (
<Card padding="lg" variant="elevated">
{!imageUri ? (
<>
<Text variant="h4" className="mb-4 text-center font-semibold text-gray-900">
Bild auswählen
</Text>
<View className="flex-row space-x-3">
<Pressable
onPress={() => pickImage('camera')}
className="flex-1 items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="camera" size={32} color="#6B7280" library="Ionicons" />
<Text variant="caption" className="mt-2 text-gray-600">
Foto aufnehmen
</Text>
</Pressable>
<View className="flex-row space-x-3">
<Pressable
onPress={() => pickImage('camera')}
className="flex-1 items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="camera" size={32} color="#6B7280" library="Ionicons" />
<Text variant="caption" className="mt-2 text-gray-600">
Foto aufnehmen
</Text>
</Pressable>
<Pressable
onPress={() => pickImage('library')}
className="flex-1 items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="images" size={32} color="#6B7280" library="Ionicons" />
<Text variant="caption" className="mt-2 text-gray-600">
Aus Galerie
</Text>
</Pressable>
</View>
<Pressable
onPress={() => pickImage('library')}
className="flex-1 items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="images" size={32} color="#6B7280" library="Ionicons" />
<Text variant="caption" className="mt-2 text-gray-600">
Aus Galerie
</Text>
</Pressable>
</View>
<Text variant="small" className="mt-4 text-center text-gray-500">
Fotografiere Lehrbücher, Notizen, Whiteboards oder Diagramme
</Text>
</>
) : (
<>
{/* Image Preview */}
<View className="relative mb-4">
<Image
source={{ uri: imageUri }}
className="h-64 w-full rounded-lg"
resizeMode="cover"
/>
<Text variant="small" className="mt-4 text-center text-gray-500">
Fotografiere Lehrbücher, Notizen, Whiteboards oder Diagramme
</Text>
</>
) : (
<>
{/* Image Preview */}
<View className="relative mb-4">
<Image
source={{ uri: imageUri }}
className="h-64 w-full rounded-lg"
resizeMode="cover"
/>
<Pressable
onPress={removeImage}
className="absolute right-2 top-2 rounded-full bg-red-500 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="close" size={20} color="white" library="Ionicons" />
</Pressable>
</View>
<Pressable
onPress={removeImage}
className="absolute right-2 top-2 rounded-full bg-red-500 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="close" size={20} color="white" library="Ionicons" />
</Pressable>
</View>
{/* Context Input */}
<View className="mb-4">
<Text variant="caption" className="mb-2 font-medium text-gray-700">
Kontext (optional)
</Text>
<TextInput
value={context}
onChangeText={setContext}
placeholder="Z.B. 'Mathematik Kapitel 3' oder 'Biologie Zellaufbau'"
className="rounded-lg border border-gray-200 bg-white p-3 text-gray-900"
/>
</View>
{/* Context Input */}
<View className="mb-4">
<Text variant="caption" className="mb-2 font-medium text-gray-700">
Kontext (optional)
</Text>
<TextInput
value={context}
onChangeText={setContext}
placeholder="Z.B. 'Mathematik Kapitel 3' oder 'Biologie Zellaufbau'"
className="rounded-lg border border-gray-200 bg-white p-3 text-gray-900"
/>
</View>
{/* Generate Button */}
<Button
onPress={handleGenerateCards}
variant="primary"
fullWidth
disabled={isGenerating}
leftIcon={
isGenerating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Icon name="sparkles" size={20} color="white" library="Ionicons" />
)
}>
{isGenerating ? 'Analysiere Bild...' : 'Karten aus Bild generieren'}
</Button>
</>
)}
{/* Generate Button */}
<Button
onPress={handleGenerateCards}
variant="primary"
fullWidth
disabled={isGenerating}
leftIcon={
isGenerating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Icon name="sparkles" size={20} color="white" library="Ionicons" />
)
}
>
{isGenerating ? 'Analysiere Bild...' : 'Karten aus Bild generieren'}
</Button>
</>
)}
{/* Error Display */}
{error && (
<View className="mt-4 rounded-lg bg-red-50 p-3">
<Text variant="caption" className="text-red-600">
{error}
</Text>
</View>
)}
{/* Error Display */}
{error && (
<View className="mt-4 rounded-lg bg-red-50 p-3">
<Text variant="caption" className="text-red-600">
{error}
</Text>
</View>
)}
{/* Info Box */}
{imageUri && !isGenerating && (
<View className="mt-4 rounded-lg bg-blue-50 p-3">
<View className="flex-row items-start">
<Icon name="information-circle" size={20} color="#3B82F6" library="Ionicons" />
<Text variant="small" className="ml-2 flex-1 text-blue-700">
Die KI erkennt Text, Diagramme und Konzepte im Bild und erstellt daraus passende
Lernkarten.
</Text>
</View>
</View>
)}
</Card>
);
{/* Info Box */}
{imageUri && !isGenerating && (
<View className="mt-4 rounded-lg bg-blue-50 p-3">
<View className="flex-row items-start">
<Icon name="information-circle" size={20} color="#3B82F6" library="Ionicons" />
<Text variant="small" className="ml-2 flex-1 text-blue-700">
Die KI erkennt Text, Diagramme und Konzepte im Bild und erstellt daraus passende
Lernkarten.
</Text>
</View>
</View>
)}
</Card>
);
};

View file

@ -1,13 +1,13 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
ScrollView,
Pressable,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
View,
Text,
TextInput,
ScrollView,
Pressable,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { Icon } from '../ui/Icon';
import { Card } from '../ui/Card';
@ -17,385 +17,400 @@ import { GeneratedCard, GenerationOptions } from '../../utils/supabaseAIService'
import { ImageCardCreator } from './ImageCardCreator';
interface SmartCardCreatorProps {
deckId: string;
onCardsCreated?: (cards: GeneratedCard[]) => void;
deckId: string;
onCardsCreated?: (cards: GeneratedCard[]) => void;
}
export const SmartCardCreator: React.FC<SmartCardCreatorProps> = ({ deckId, onCardsCreated }) => {
const {
isGenerating,
generatedCards,
error,
generateCardsFromText,
clearGeneratedCards,
saveGeneratedCards,
} = useAIStore();
const {
isGenerating,
generatedCards,
error,
generateCardsFromText,
clearGeneratedCards,
saveGeneratedCards,
} = useAIStore();
const [inputMode, setInputMode] = useState<'text' | 'image'>('text');
const [textInput, setTextInput] = useState('');
const [options, setOptions] = useState<GenerationOptions>({
cardTypes: ['flashcard', 'quiz'],
difficulty: 'medium',
count: 5,
language: 'de',
});
const [selectedCards, setSelectedCards] = useState<Set<number>>(new Set());
const [inputMode, setInputMode] = useState<'text' | 'image'>('text');
const [textInput, setTextInput] = useState('');
const [options, setOptions] = useState<GenerationOptions>({
cardTypes: ['flashcard', 'quiz'],
difficulty: 'medium',
count: 5,
language: 'de',
});
const [selectedCards, setSelectedCards] = useState<Set<number>>(new Set());
const handleGenerateFromText = async () => {
if (!textInput.trim()) return;
const handleGenerateFromText = async () => {
if (!textInput.trim()) return;
try {
const cards = await generateCardsFromText(textInput, options);
if (onCardsCreated) {
onCardsCreated(cards);
}
} catch (error) {
console.error('Error generating cards:', error);
}
};
try {
const cards = await generateCardsFromText(textInput, options);
if (onCardsCreated) {
onCardsCreated(cards);
}
} catch (error) {
console.error('Error generating cards:', error);
}
};
const handleSaveSelectedCards = async () => {
const cardsToSave = generatedCards.filter((_, index) => selectedCards.has(index));
if (cardsToSave.length === 0) return;
const handleSaveSelectedCards = async () => {
const cardsToSave = generatedCards.filter((_, index) => selectedCards.has(index));
if (cardsToSave.length === 0) return;
try {
await saveGeneratedCards(deckId, cardsToSave);
clearGeneratedCards();
setSelectedCards(new Set());
if (onCardsCreated) {
onCardsCreated(cardsToSave);
}
} catch (error) {
console.error('Error saving cards:', error);
}
};
try {
await saveGeneratedCards(deckId, cardsToSave);
clearGeneratedCards();
setSelectedCards(new Set());
if (onCardsCreated) {
onCardsCreated(cardsToSave);
}
} catch (error) {
console.error('Error saving cards:', error);
}
};
const toggleCardSelection = (index: number) => {
const newSelection = new Set(selectedCards);
if (newSelection.has(index)) {
newSelection.delete(index);
} else {
newSelection.add(index);
}
setSelectedCards(newSelection);
};
const toggleCardSelection = (index: number) => {
const newSelection = new Set(selectedCards);
if (newSelection.has(index)) {
newSelection.delete(index);
} else {
newSelection.add(index);
}
setSelectedCards(newSelection);
};
const selectAllCards = () => {
if (selectedCards.size === generatedCards.length) {
setSelectedCards(new Set());
} else {
setSelectedCards(new Set(generatedCards.map((_, i) => i)));
}
};
const selectAllCards = () => {
if (selectedCards.size === generatedCards.length) {
setSelectedCards(new Set());
} else {
setSelectedCards(new Set(generatedCards.map((_, i) => i)));
}
};
const renderGeneratedCard = (card: GeneratedCard, index: number) => {
const isSelected = selectedCards.has(index);
const renderGeneratedCard = (card: GeneratedCard, index: number) => {
const isSelected = selectedCards.has(index);
return (
<Pressable
key={index}
onPress={() => toggleCardSelection(index)}
className="mb-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Card
padding="md"
variant={isSelected ? 'primary' : 'outlined'}
className={isSelected ? 'border-2 border-blue-500' : ''}>
<View className="flex-row items-start justify-between">
<View className="flex-1">
<View className="mb-2 flex-row items-center">
<View
className={`rounded px-2 py-1 ${
card.type === 'flashcard'
? 'bg-blue-100'
: card.type === 'quiz'
? 'bg-purple-100'
: 'bg-gray-100'
}`}>
<Text
className={`text-xs font-medium ${
card.type === 'flashcard'
? 'text-blue-700'
: card.type === 'quiz'
? 'text-purple-700'
: 'text-gray-700'
}`}>
{card.type.toUpperCase()}
</Text>
</View>
<View className="ml-2 flex-row items-center">
<Icon name="speedometer-outline" size={12} color="#6B7280" library="Ionicons" />
<Text className="ml-1 text-xs text-gray-500">
{Math.round(card.metadata.confidence * 100)}% Konfidenz
</Text>
</View>
</View>
return (
<Pressable
key={index}
onPress={() => toggleCardSelection(index)}
className="mb-3"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Card
padding="md"
variant={isSelected ? 'primary' : 'outlined'}
className={isSelected ? 'border-2 border-blue-500' : ''}
>
<View className="flex-row items-start justify-between">
<View className="flex-1">
<View className="mb-2 flex-row items-center">
<View
className={`rounded px-2 py-1 ${
card.type === 'flashcard'
? 'bg-blue-100'
: card.type === 'quiz'
? 'bg-purple-100'
: 'bg-gray-100'
}`}
>
<Text
className={`text-xs font-medium ${
card.type === 'flashcard'
? 'text-blue-700'
: card.type === 'quiz'
? 'text-purple-700'
: 'text-gray-700'
}`}
>
{card.type.toUpperCase()}
</Text>
</View>
<View className="ml-2 flex-row items-center">
<Icon name="speedometer-outline" size={12} color="#6B7280" library="Ionicons" />
<Text className="ml-1 text-xs text-gray-500">
{Math.round(card.metadata.confidence * 100)}% Konfidenz
</Text>
</View>
</View>
{card.type === 'flashcard' && card.content && 'front' in card.content && (
<>
<Text className="font-semibold text-gray-900">{card.content.front}</Text>
<Text className="mt-1 text-sm text-gray-600">{card.content.back}</Text>
</>
)}
{card.type === 'flashcard' && card.content && 'front' in card.content && (
<>
<Text className="font-semibold text-gray-900">{card.content.front}</Text>
<Text className="mt-1 text-sm text-gray-600">{card.content.back}</Text>
</>
)}
{card.type === 'quiz' && card.content && 'question' in card.content && (
<>
<Text className="font-semibold text-gray-900">{card.content.question}</Text>
<View className="mt-1">
{card.content.options.map((option: string, i: number) => (
<Text
key={i}
className={`text-sm ${
i === card.content.correctAnswer
? 'font-medium text-green-600'
: 'text-gray-600'
}`}>
{i + 1}. {option}
</Text>
))}
</View>
</>
)}
{card.type === 'quiz' && card.content && 'question' in card.content && (
<>
<Text className="font-semibold text-gray-900">{card.content.question}</Text>
<View className="mt-1">
{card.content.options.map((option: string, i: number) => (
<Text
key={i}
className={`text-sm ${
i === card.content.correctAnswer
? 'font-medium text-green-600'
: 'text-gray-600'
}`}
>
{i + 1}. {option}
</Text>
))}
</View>
</>
)}
{card.type === 'text' && card.content && 'text' in card.content && (
<Text className="text-gray-900">{card.content.text}</Text>
)}
</View>
{card.type === 'text' && card.content && 'text' in card.content && (
<Text className="text-gray-900">{card.content.text}</Text>
)}
</View>
<View className="ml-2">
<Icon
name={isSelected ? 'checkbox' : 'square-outline'}
size={24}
color={isSelected ? '#3B82F6' : '#9CA3AF'}
library="Ionicons"
/>
</View>
</View>
</Card>
</Pressable>
);
};
<View className="ml-2">
<Icon
name={isSelected ? 'checkbox' : 'square-outline'}
size={24}
color={isSelected ? '#3B82F6' : '#9CA3AF'}
library="Ionicons"
/>
</View>
</View>
</Card>
</Pressable>
);
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1">
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{/* Input Mode Selector */}
<View className="mb-4 flex-row space-x-2">
{[
{ key: 'text', label: 'Text', icon: 'text' },
{ key: 'image', label: 'Bild', icon: 'image' },
].map((mode) => (
<Pressable
key={mode.key}
onPress={() => setInputMode(mode.key as any)}
className={`flex-1 flex-row items-center justify-center rounded-lg border p-3 ${
inputMode === mode.key ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon
name={mode.icon}
size={20}
color={inputMode === mode.key ? '#3B82F6' : '#6B7280'}
library="Ionicons"
/>
<Text
className={`ml-2 font-medium ${
inputMode === mode.key ? 'text-blue-600' : 'text-gray-600'
}`}>
{mode.label}
</Text>
</Pressable>
))}
</View>
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{/* Input Mode Selector */}
<View className="mb-4 flex-row space-x-2">
{[
{ key: 'text', label: 'Text', icon: 'text' },
{ key: 'image', label: 'Bild', icon: 'image' },
].map((mode) => (
<Pressable
key={mode.key}
onPress={() => setInputMode(mode.key as any)}
className={`flex-1 flex-row items-center justify-center rounded-lg border p-3 ${
inputMode === mode.key ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon
name={mode.icon}
size={20}
color={inputMode === mode.key ? '#3B82F6' : '#6B7280'}
library="Ionicons"
/>
<Text
className={`ml-2 font-medium ${
inputMode === mode.key ? 'text-blue-600' : 'text-gray-600'
}`}
>
{mode.label}
</Text>
</Pressable>
))}
</View>
{/* Input Area */}
{inputMode === 'text' && (
<Card padding="lg" variant="elevated" className="mb-4">
<Text className="mb-2 text-sm font-medium text-gray-700">
Gib deinen Lerninhalt ein
</Text>
<TextInput
value={textInput}
onChangeText={setTextInput}
placeholder="Füge hier deinen Text ein oder beschreibe, was du lernen möchtest..."
multiline
numberOfLines={6}
className="rounded-lg border border-gray-200 bg-white p-3 text-gray-900"
style={{ textAlignVertical: 'top' }}
/>
</Card>
)}
{/* Input Area */}
{inputMode === 'text' && (
<Card padding="lg" variant="elevated" className="mb-4">
<Text className="mb-2 text-sm font-medium text-gray-700">
Gib deinen Lerninhalt ein
</Text>
<TextInput
value={textInput}
onChangeText={setTextInput}
placeholder="Füge hier deinen Text ein oder beschreibe, was du lernen möchtest..."
multiline
numberOfLines={6}
className="rounded-lg border border-gray-200 bg-white p-3 text-gray-900"
style={{ textAlignVertical: 'top' }}
/>
</Card>
)}
{inputMode === 'image' && (
<View className="mb-4">
<ImageCardCreator
onCardsGenerated={(cards) => {
// Add to generated cards
if (onCardsCreated) {
onCardsCreated(cards);
}
}}
/>
</View>
)}
{inputMode === 'image' && (
<View className="mb-4">
<ImageCardCreator
onCardsGenerated={(cards) => {
// Add to generated cards
if (onCardsCreated) {
onCardsCreated(cards);
}
}}
/>
</View>
)}
{/* Generation Options */}
{inputMode === 'text' && (
<Card padding="lg" variant="elevated" className="mb-4">
<Text className="mb-3 text-sm font-medium text-gray-700">Optionen</Text>
{/* Generation Options */}
{inputMode === 'text' && (
<Card padding="lg" variant="elevated" className="mb-4">
<Text className="mb-3 text-sm font-medium text-gray-700">Optionen</Text>
{/* Card Types */}
<View className="mb-3">
<Text className="mb-2 text-xs text-gray-500">Kartentypen</Text>
<View className="flex-row flex-wrap">
{(['flashcard', 'quiz', 'text'] as const).map((type) => (
<Pressable
key={type}
onPress={() => {
const types = options.cardTypes || [];
if (types.includes(type)) {
setOptions({
...options,
cardTypes: types.filter((t) => t !== type),
});
} else {
setOptions({
...options,
cardTypes: [...types, type],
});
}
}}
className={`mb-2 mr-2 rounded-full border px-3 py-1 ${
options.cardTypes?.includes(type)
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text
className={`text-sm ${
options.cardTypes?.includes(type) ? 'text-blue-600' : 'text-gray-600'
}`}>
{type === 'flashcard' ? 'Karteikarte' : type === 'quiz' ? 'Quiz' : 'Text'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Card Types */}
<View className="mb-3">
<Text className="mb-2 text-xs text-gray-500">Kartentypen</Text>
<View className="flex-row flex-wrap">
{(['flashcard', 'quiz', 'text'] as const).map((type) => (
<Pressable
key={type}
onPress={() => {
const types = options.cardTypes || [];
if (types.includes(type)) {
setOptions({
...options,
cardTypes: types.filter((t) => t !== type),
});
} else {
setOptions({
...options,
cardTypes: [...types, type],
});
}
}}
className={`mb-2 mr-2 rounded-full border px-3 py-1 ${
options.cardTypes?.includes(type)
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Text
className={`text-sm ${
options.cardTypes?.includes(type) ? 'text-blue-600' : 'text-gray-600'
}`}
>
{type === 'flashcard' ? 'Karteikarte' : type === 'quiz' ? 'Quiz' : 'Text'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Difficulty */}
<View className="mb-3">
<Text className="mb-2 text-xs text-gray-500">Schwierigkeit</Text>
<View className="flex-row space-x-2">
{(['easy', 'medium', 'hard'] as const).map((level) => (
<Pressable
key={level}
onPress={() => setOptions({ ...options, difficulty: level })}
className={`flex-1 rounded-lg border p-2 ${
options.difficulty === level
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text
className={`text-center text-sm ${
options.difficulty === level ? 'text-blue-600' : 'text-gray-600'
}`}>
{level === 'easy' ? 'Einfach' : level === 'medium' ? 'Mittel' : 'Schwer'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Difficulty */}
<View className="mb-3">
<Text className="mb-2 text-xs text-gray-500">Schwierigkeit</Text>
<View className="flex-row space-x-2">
{(['easy', 'medium', 'hard'] as const).map((level) => (
<Pressable
key={level}
onPress={() => setOptions({ ...options, difficulty: level })}
className={`flex-1 rounded-lg border p-2 ${
options.difficulty === level
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 bg-white'
}`}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Text
className={`text-center text-sm ${
options.difficulty === level ? 'text-blue-600' : 'text-gray-600'
}`}
>
{level === 'easy' ? 'Einfach' : level === 'medium' ? 'Mittel' : 'Schwer'}
</Text>
</Pressable>
))}
</View>
</View>
{/* Count */}
<View>
<Text className="mb-2 text-xs text-gray-500">Anzahl Karten: {options.count}</Text>
<View className="flex-row items-center">
<Pressable
onPress={() =>
setOptions({ ...options, count: Math.max(1, (options.count || 5) - 1) })
}
className="rounded-lg bg-gray-200 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="remove" size={20} color="#374151" library="Ionicons" />
</Pressable>
<Text className="mx-4 text-lg font-semibold">{options.count}</Text>
<Pressable
onPress={() =>
setOptions({ ...options, count: Math.min(20, (options.count || 5) + 1) })
}
className="rounded-lg bg-gray-200 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="add" size={20} color="#374151" library="Ionicons" />
</Pressable>
</View>
</View>
</Card>
)}
{/* Count */}
<View>
<Text className="mb-2 text-xs text-gray-500">Anzahl Karten: {options.count}</Text>
<View className="flex-row items-center">
<Pressable
onPress={() =>
setOptions({ ...options, count: Math.max(1, (options.count || 5) - 1) })
}
className="rounded-lg bg-gray-200 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="remove" size={20} color="#374151" library="Ionicons" />
</Pressable>
<Text className="mx-4 text-lg font-semibold">{options.count}</Text>
<Pressable
onPress={() =>
setOptions({ ...options, count: Math.min(20, (options.count || 5) + 1) })
}
className="rounded-lg bg-gray-200 p-2"
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Icon name="add" size={20} color="#374151" library="Ionicons" />
</Pressable>
</View>
</View>
</Card>
)}
{/* Generate Button */}
{inputMode === 'text' && textInput.trim() && (
<Button
onPress={handleGenerateFromText}
variant="primary"
fullWidth
disabled={isGenerating}
leftIcon={
isGenerating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Icon name="sparkles" size={20} color="white" library="Ionicons" />
)
}>
{isGenerating ? 'Generiere Karten...' : 'Karten generieren'}
</Button>
)}
{/* Generate Button */}
{inputMode === 'text' && textInput.trim() && (
<Button
onPress={handleGenerateFromText}
variant="primary"
fullWidth
disabled={isGenerating}
leftIcon={
isGenerating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Icon name="sparkles" size={20} color="white" library="Ionicons" />
)
}
>
{isGenerating ? 'Generiere Karten...' : 'Karten generieren'}
</Button>
)}
{/* Error Display */}
{error && (
<Card padding="md" variant="outlined" className="mb-4 border-red-200 bg-red-50">
<Text className="text-sm text-red-600">{error}</Text>
</Card>
)}
{/* Error Display */}
{error && (
<Card padding="md" variant="outlined" className="mb-4 border-red-200 bg-red-50">
<Text className="text-sm text-red-600">{error}</Text>
</Card>
)}
{/* Generated Cards */}
{generatedCards.length > 0 && (
<View className="mt-6">
<View className="mb-3 flex-row items-center justify-between">
<Text className="text-lg font-semibold text-gray-900">
Generierte Karten ({selectedCards.size}/{generatedCards.length})
</Text>
<Pressable
onPress={selectAllCards}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Text className="text-sm font-medium text-blue-600">
{selectedCards.size === generatedCards.length
? 'Keine auswählen'
: 'Alle auswählen'}
</Text>
</Pressable>
</View>
{/* Generated Cards */}
{generatedCards.length > 0 && (
<View className="mt-6">
<View className="mb-3 flex-row items-center justify-between">
<Text className="text-lg font-semibold text-gray-900">
Generierte Karten ({selectedCards.size}/{generatedCards.length})
</Text>
<Pressable
onPress={selectAllCards}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Text className="text-sm font-medium text-blue-600">
{selectedCards.size === generatedCards.length
? 'Keine auswählen'
: 'Alle auswählen'}
</Text>
</Pressable>
</View>
{generatedCards.map((card, index) => renderGeneratedCard(card, index))}
{generatedCards.map((card, index) => renderGeneratedCard(card, index))}
{selectedCards.size > 0 && (
<View className="mt-4 flex-row space-x-2">
<Button
onPress={handleSaveSelectedCards}
variant="primary"
fullWidth
leftIcon={
<Icon name="save-outline" size={20} color="white" library="Ionicons" />
}>
{selectedCards.size} Karten speichern
</Button>
</View>
)}
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
);
{selectedCards.size > 0 && (
<View className="mt-4 flex-row space-x-2">
<Button
onPress={handleSaveSelectedCards}
variant="primary"
fullWidth
leftIcon={<Icon name="save-outline" size={20} color="white" library="Ionicons" />}
>
{selectedCards.size} Karten speichern
</Button>
</View>
)}
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
);
};

View file

@ -7,102 +7,103 @@ import { useThemeColors } from '~/utils/themeUtils';
import { Ionicons } from '@expo/vector-icons';
interface AppleSignInButtonProps {
onSignInSuccess?: () => void;
onSignInError?: (error: string) => void;
onSignIn: (identityToken: string) => Promise<void>;
onSignInSuccess?: () => void;
onSignInError?: (error: string) => void;
onSignIn: (identityToken: string) => Promise<void>;
}
export const AppleSignInButton: React.FC<AppleSignInButtonProps> = ({
onSignInSuccess,
onSignInError,
onSignIn,
onSignInSuccess,
onSignInError,
onSignIn,
}) => {
const colors = useThemeColors();
const [isLoading, setIsLoading] = useState(false);
const colors = useThemeColors();
const [isLoading, setIsLoading] = useState(false);
// Apple Sign-In is iOS only
if (Platform.OS !== 'ios') {
return null;
}
// Apple Sign-In is iOS only
if (Platform.OS !== 'ios') {
return null;
}
const handleAppleSignIn = async () => {
setIsLoading(true);
try {
// Trigger Apple Sign-In
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
const handleAppleSignIn = async () => {
setIsLoading(true);
try {
// Trigger Apple Sign-In
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
const identityToken = credential.identityToken;
const identityToken = credential.identityToken;
if (!identityToken) {
Alert.alert('Anmeldung fehlgeschlagen', 'Kein Identity Token von Apple erhalten');
if (onSignInError) {
onSignInError('Kein Identity Token erhalten');
}
return;
}
if (!identityToken) {
Alert.alert('Anmeldung fehlgeschlagen', 'Kein Identity Token von Apple erhalten');
if (onSignInError) {
onSignInError('Kein Identity Token erhalten');
}
return;
}
console.log('Got Apple identity token');
console.log('Got Apple identity token');
// Send to backend for validation
await onSignIn(identityToken);
// Send to backend for validation
await onSignIn(identityToken);
if (onSignInSuccess) {
onSignInSuccess();
}
} catch (error: any) {
if (error.code === 'ERR_REQUEST_CANCELED') {
console.log('User cancelled Apple sign-in');
} else {
console.error('Apple Sign-In Error:', error);
const errorMessage = error.message || 'Anmeldung mit Apple fehlgeschlagen';
Alert.alert('Anmeldefehler', errorMessage);
if (onSignInError) {
onSignInError(errorMessage);
}
}
} finally {
setIsLoading(false);
}
};
if (onSignInSuccess) {
onSignInSuccess();
}
} catch (error: any) {
if (error.code === 'ERR_REQUEST_CANCELED') {
console.log('User cancelled Apple sign-in');
} else {
console.error('Apple Sign-In Error:', error);
const errorMessage = error.message || 'Anmeldung mit Apple fehlgeschlagen';
Alert.alert('Anmeldefehler', errorMessage);
if (onSignInError) {
onSignInError(errorMessage);
}
}
} finally {
setIsLoading(false);
}
};
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: '#000000' }]}
onPress={handleAppleSignIn}
disabled={isLoading}
activeOpacity={0.7}>
{isLoading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons name="logo-apple" size={20} color="#FFFFFF" style={styles.icon} />
<Text style={styles.buttonText}>Mit Apple anmelden</Text>
</>
)}
</TouchableOpacity>
);
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: '#000000' }]}
onPress={handleAppleSignIn}
disabled={isLoading}
activeOpacity={0.7}
>
{isLoading ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<>
<Ionicons name="logo-apple" size={20} color="#FFFFFF" style={styles.icon} />
<Text style={styles.buttonText}>Mit Apple anmelden</Text>
</>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
borderRadius: 8,
minHeight: 52,
},
icon: {
marginRight: 12,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
borderRadius: 8,
minHeight: 52,
},
icon: {
marginRight: 12,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
});

View file

@ -7,115 +7,114 @@ import { useThemeColors } from '~/utils/themeUtils';
import { Ionicons } from '@expo/vector-icons';
interface GoogleSignInButtonProps {
onSignInSuccess?: () => void;
onSignInError?: (error: string) => void;
onSignIn: (idToken: string) => Promise<void>;
onSignInSuccess?: () => void;
onSignInError?: (error: string) => void;
onSignIn: (idToken: string) => Promise<void>;
}
export const GoogleSignInButton: React.FC<GoogleSignInButtonProps> = ({
onSignInSuccess,
onSignInError,
onSignIn,
onSignInSuccess,
onSignInError,
onSignIn,
}) => {
const colors = useThemeColors();
const [isLoading, setIsLoading] = useState(false);
const colors = useThemeColors();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
// Configure Google Sign-In
GoogleSignin.configure({
webClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
scopes: ['profile', 'email'],
offlineAccess: false,
});
}, []);
useEffect(() => {
// Configure Google Sign-In
GoogleSignin.configure({
webClientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID,
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
scopes: ['profile', 'email'],
offlineAccess: false,
});
}, []);
const handleGoogleSignIn = async () => {
setIsLoading(true);
try {
// Check Google Play Services (Android only)
if (Platform.OS === 'android') {
await GoogleSignin.hasPlayServices();
}
const handleGoogleSignIn = async () => {
setIsLoading(true);
try {
// Check Google Play Services (Android only)
if (Platform.OS === 'android') {
await GoogleSignin.hasPlayServices();
}
// Trigger Google Sign-In
await GoogleSignin.signIn();
// Trigger Google Sign-In
await GoogleSignin.signIn();
// Get ID token
const tokens = await GoogleSignin.getTokens();
const idToken = tokens.idToken;
// Get ID token
const tokens = await GoogleSignin.getTokens();
const idToken = tokens.idToken;
console.log('Got Google ID token');
console.log('Got Google ID token');
// Send to backend for validation
await onSignIn(idToken);
// Send to backend for validation
await onSignIn(idToken);
if (onSignInSuccess) {
onSignInSuccess();
}
} catch (error: any) {
console.error('Google Sign-In Error:', error);
if (onSignInSuccess) {
onSignInSuccess();
}
} catch (error: any) {
console.error('Google Sign-In Error:', error);
// Handle specific error codes
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
console.log('User cancelled sign-in');
} else if (error.code === statusCodes.IN_PROGRESS) {
console.log('Sign-in already in progress');
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
Alert.alert(
'Google Play Services',
'Google Play Services ist nicht verfügbar oder veraltet. Bitte aktualisieren Sie es.'
);
if (onSignInError) {
onSignInError('Google Play Services nicht verfügbar');
}
} else {
const errorMessage = error.message || 'Anmeldung mit Google fehlgeschlagen';
Alert.alert('Anmeldefehler', errorMessage);
if (onSignInError) {
onSignInError(errorMessage);
}
}
} finally {
setIsLoading(false);
}
};
// Handle specific error codes
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
console.log('User cancelled sign-in');
} else if (error.code === statusCodes.IN_PROGRESS) {
console.log('Sign-in already in progress');
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
Alert.alert(
'Google Play Services',
'Google Play Services ist nicht verfügbar oder veraltet. Bitte aktualisieren Sie es.'
);
if (onSignInError) {
onSignInError('Google Play Services nicht verfügbar');
}
} else {
const errorMessage = error.message || 'Anmeldung mit Google fehlgeschlagen';
Alert.alert('Anmeldefehler', errorMessage);
if (onSignInError) {
onSignInError(errorMessage);
}
}
} finally {
setIsLoading(false);
}
};
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: '#FFFFFF', borderColor: colors.border }]}
onPress={handleGoogleSignIn}
disabled={isLoading}
activeOpacity={0.7}>
{isLoading ? (
<ActivityIndicator size="small" color="#4285F4" />
) : (
<>
<Ionicons name="logo-google" size={20} color="#4285F4" style={styles.icon} />
<Text style={[styles.buttonText, { color: colors.foreground }]}>
Mit Google anmelden
</Text>
</>
)}
</TouchableOpacity>
);
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: '#FFFFFF', borderColor: colors.border }]}
onPress={handleGoogleSignIn}
disabled={isLoading}
activeOpacity={0.7}
>
{isLoading ? (
<ActivityIndicator size="small" color="#4285F4" />
) : (
<>
<Ionicons name="logo-google" size={20} color="#4285F4" style={styles.icon} />
<Text style={[styles.buttonText, { color: colors.foreground }]}>Mit Google anmelden</Text>
</>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
borderRadius: 8,
borderWidth: 1,
minHeight: 52,
},
icon: {
marginRight: 12,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
},
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
borderRadius: 8,
borderWidth: 1,
minHeight: 52,
},
icon: {
marginRight: 12,
},
buttonText: {
fontSize: 16,
fontWeight: '600',
},
});

View file

@ -9,332 +9,384 @@ import { Button } from '../ui/Button';
import { useThemeColors } from '~/utils/themeUtils';
interface CardListProps {
deckId: string;
isCompact?: boolean;
showActions?: boolean;
limit?: number;
onCardPress?: (card: Card) => void;
deckId: string;
isCompact?: boolean;
showActions?: boolean;
limit?: number;
onCardPress?: (card: Card) => void;
}
interface CardItemProps {
card: Card;
isCompact: boolean;
showActions: boolean;
onPress: () => void;
onEdit: () => void;
onDelete: () => void;
onToggleFavorite: () => void;
card: Card;
isCompact: boolean;
showActions: boolean;
onPress: () => void;
onEdit: () => void;
onDelete: () => void;
onToggleFavorite: () => void;
}
const CardItem: React.FC<CardItemProps> = ({
card,
isCompact,
showActions,
onPress,
onEdit,
onDelete,
onToggleFavorite,
card,
isCompact,
showActions,
onPress,
onEdit,
onDelete,
onToggleFavorite,
}) => {
const colors = useThemeColors();
const getCardTypeIcon = (type: Card['card_type']) => {
switch (type) {
case 'text':
return 'document-text-outline';
case 'flashcard':
return 'card-outline';
case 'quiz':
return 'help-circle-outline';
case 'mixed':
return 'grid-outline';
default:
return 'document-outline';
}
};
const colors = useThemeColors();
const getCardTypeColor = (type: Card['card_type']) => {
switch (type) {
case 'text':
return colors.primary;
case 'flashcard':
return colors.accent;
case 'quiz':
return colors.secondary;
case 'mixed':
return colors.destructive;
default:
return colors.mutedForeground;
}
};
const getCardTypeIcon = (type: Card['card_type']) => {
switch (type) {
case 'text':
return 'document-text-outline';
case 'flashcard':
return 'card-outline';
case 'quiz':
return 'help-circle-outline';
case 'mixed':
return 'grid-outline';
default:
return 'document-outline';
}
};
const getContentPreview = (card: Card) => {
switch (card.card_type) {
case 'text':
return (card.content as any).text || '';
case 'flashcard':
return (card.content as any).front || '';
case 'quiz':
return (card.content as any).question || '';
case 'mixed':
const blocks = (card.content as any).blocks || [];
const firstTextBlock = blocks.find((block: any) => block.type === 'text');
return firstTextBlock?.data?.text || '';
default:
return '';
}
};
const getCardTypeColor = (type: Card['card_type']) => {
switch (type) {
case 'text':
return colors.primary;
case 'flashcard':
return colors.accent;
case 'quiz':
return colors.secondary;
case 'mixed':
return colors.destructive;
default:
return colors.mutedForeground;
}
};
if (isCompact) {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<View
style={{
marginBottom: 10,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 14,
padding: 14,
borderWidth: 1,
borderColor: '#E5E7EB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 4,
elevation: 2,
opacity: pressed ? 0.7 : 1,
}}>
{/* Icon Container */}
<View
style={{
width: 50,
height: 50,
backgroundColor: getCardTypeColor(card.card_type),
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
}}>
<Icon
name={getCardTypeIcon(card.card_type)}
size={26}
color="#FFFFFF"
library="Ionicons"
/>
</View>
const getContentPreview = (card: Card) => {
switch (card.card_type) {
case 'text':
return (card.content as any).text || '';
case 'flashcard':
return (card.content as any).front || '';
case 'quiz':
return (card.content as any).question || '';
case 'mixed':
const blocks = (card.content as any).blocks || [];
const firstTextBlock = blocks.find((block: any) => block.type === 'text');
return firstTextBlock?.data?.text || '';
default:
return '';
}
};
{/* Content */}
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
marginBottom: 4,
}}
numberOfLines={1}>
{card.title || `${card.card_type.charAt(0).toUpperCase() + card.card_type.slice(1)} Karte`}
</Text>
<Text
style={{
fontSize: 14,
color: '#6B7280',
}}
numberOfLines={1}>
{getContentPreview(card)}
</Text>
</View>
if (isCompact) {
return (
<Pressable onPress={onPress}>
{({ pressed }) => (
<View
style={{
marginBottom: 10,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 14,
padding: 14,
borderWidth: 1,
borderColor: '#E5E7EB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 4,
elevation: 2,
opacity: pressed ? 0.7 : 1,
}}
>
{/* Icon Container */}
<View
style={{
width: 50,
height: 50,
backgroundColor: getCardTypeColor(card.card_type),
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
}}
>
<Icon
name={getCardTypeIcon(card.card_type)}
size={26}
color="#FFFFFF"
library="Ionicons"
/>
</View>
{/* Chevron */}
<Icon
name="chevron-forward"
size={20}
color="#9CA3AF"
library="Ionicons"
style={{ marginLeft: 8 }}
/>
</View>
)}
</Pressable>
);
}
{/* Content */}
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 16,
fontWeight: '600',
color: '#1F2937',
marginBottom: 4,
}}
numberOfLines={1}
>
{card.title ||
`${card.card_type.charAt(0).toUpperCase() + card.card_type.slice(1)} Karte`}
</Text>
<Text
style={{
fontSize: 14,
color: '#6B7280',
}}
numberOfLines={1}
>
{getContentPreview(card)}
</Text>
</View>
return (
<UICard onPress={onPress} variant="elevated" padding="lg" style={{ marginBottom: 16 }}>
<View style={{ marginBottom: 8, flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<View style={{ marginBottom: 8, flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 24,
width: 24,
backgroundColor: getCardTypeColor(card.card_type),
marginRight: 8,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
}}>
<Icon
name={getCardTypeIcon(card.card_type)}
size={12}
color="white"
library="Ionicons"
/>
</View>
<Text style={{ fontSize: 12, fontWeight: '500', textTransform: 'uppercase', letterSpacing: 0.5, color: colors.mutedForeground }}>
{card.card_type}
</Text>
{card.is_favorite && (
<Icon
name="heart"
size={14}
color={colors.destructive}
style={{ marginLeft: 8 }}
library="Ionicons"
/>
)}
</View>
{/* Chevron */}
<Icon
name="chevron-forward"
size={20}
color="#9CA3AF"
library="Ionicons"
style={{ marginLeft: 8 }}
/>
</View>
)}
</Pressable>
);
}
<Text style={{ marginBottom: 4, fontSize: 18, fontWeight: '600', color: colors.foreground }}>
{card.title || `Position ${card.position}`}
</Text>
return (
<UICard onPress={onPress} variant="elevated" padding="lg" style={{ marginBottom: 16 }}>
<View
style={{
marginBottom: 8,
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
}}
>
<View style={{ flex: 1 }}>
<View style={{ marginBottom: 8, flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 24,
width: 24,
backgroundColor: getCardTypeColor(card.card_type),
marginRight: 8,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
}}
>
<Icon
name={getCardTypeIcon(card.card_type)}
size={12}
color="white"
library="Ionicons"
/>
</View>
<Text
style={{
fontSize: 12,
fontWeight: '500',
textTransform: 'uppercase',
letterSpacing: 0.5,
color: colors.mutedForeground,
}}
>
{card.card_type}
</Text>
{card.is_favorite && (
<Icon
name="heart"
size={14}
color={colors.destructive}
style={{ marginLeft: 8 }}
library="Ionicons"
/>
)}
</View>
<Text style={{ color: colors.mutedForeground }} numberOfLines={3}>
{getContentPreview(card)}
</Text>
</View>
<Text
style={{ marginBottom: 4, fontSize: 18, fontWeight: '600', color: colors.foreground }}
>
{card.title || `Position ${card.position}`}
</Text>
{showActions && (
<View style={{ marginLeft: 12, flexDirection: 'row' }}>
<Pressable
onPress={onToggleFavorite}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}>
<Icon
name={card.is_favorite ? 'heart' : 'heart-outline'}
size={20}
color={card.is_favorite ? colors.destructive : colors.mutedForeground}
library="Ionicons"
/>
</Pressable>
<Pressable
onPress={onEdit}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}>
<Icon name="create-outline" size={20} color={colors.mutedForeground} library="Ionicons" />
</Pressable>
<Pressable
onPress={onDelete}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}>
<Icon name="trash-outline" size={20} color={colors.destructive} library="Ionicons" />
</Pressable>
</View>
)}
</View>
<Text style={{ color: colors.mutedForeground }} numberOfLines={3}>
{getContentPreview(card)}
</Text>
</View>
<View style={{ marginTop: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: colors.border, paddingTop: 12 }}>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>Position {card.position}</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
{new Date(card.updated_at).toLocaleDateString('de-DE')}
</Text>
</View>
</UICard>
);
{showActions && (
<View style={{ marginLeft: 12, flexDirection: 'row' }}>
<Pressable
onPress={onToggleFavorite}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}
>
<Icon
name={card.is_favorite ? 'heart' : 'heart-outline'}
size={20}
color={card.is_favorite ? colors.destructive : colors.mutedForeground}
library="Ionicons"
/>
</Pressable>
<Pressable
onPress={onEdit}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}
>
<Icon
name="create-outline"
size={20}
color={colors.mutedForeground}
library="Ionicons"
/>
</Pressable>
<Pressable
onPress={onDelete}
style={({ pressed }) => ({ padding: 8, opacity: pressed ? 0.7 : 1 })}
>
<Icon name="trash-outline" size={20} color={colors.destructive} library="Ionicons" />
</Pressable>
</View>
)}
</View>
<View
style={{
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 12,
}}
>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
Position {card.position}
</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
{new Date(card.updated_at).toLocaleDateString('de-DE')}
</Text>
</View>
</UICard>
);
};
export const CardList: React.FC<CardListProps> = ({
deckId,
isCompact = false,
showActions = true,
limit,
onCardPress,
deckId,
isCompact = false,
showActions = true,
limit,
onCardPress,
}) => {
const { cards, fetchCards, deleteCard, toggleFavorite, isLoading } = useCardStore();
const colors = useThemeColors();
const { cards, fetchCards, deleteCard, toggleFavorite, isLoading } = useCardStore();
const colors = useThemeColors();
useEffect(() => {
fetchCards(deckId);
}, [deckId]);
useEffect(() => {
fetchCards(deckId);
}, [deckId]);
const displayCards = limit ? cards.slice(0, limit) : cards;
const displayCards = limit ? cards.slice(0, limit) : cards;
const handleCardPress = (card: Card) => {
if (onCardPress) {
onCardPress(card);
} else {
router.push(`/card/${card.id}`);
}
};
const handleCardPress = (card: Card) => {
if (onCardPress) {
onCardPress(card);
} else {
router.push(`/card/${card.id}`);
}
};
const handleEdit = (card: Card) => {
router.push(`/card/edit/${card.id}`);
};
const handleEdit = (card: Card) => {
router.push(`/card/edit/${card.id}`);
};
const handleDelete = (card: Card) => {
Alert.alert('Karte löschen', 'Möchtest du diese Karte wirklich löschen?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => deleteCard(card.id),
},
]);
};
const handleDelete = (card: Card) => {
Alert.alert('Karte löschen', 'Möchtest du diese Karte wirklich löschen?', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: () => deleteCard(card.id),
},
]);
};
const handleToggleFavorite = (card: Card) => {
toggleFavorite(card.id);
};
const handleToggleFavorite = (card: Card) => {
toggleFavorite(card.id);
};
if (cards.length === 0 && !isLoading) {
return (
<UICard padding="lg" variant="outlined">
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
<Icon name="card-outline" size={48} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginTop: 8, color: colors.mutedForeground }}>Noch keine Karten</Text>
<Text style={{ marginTop: 4, textAlign: 'center', fontSize: 14, color: colors.mutedForeground }}>
Füge deine erste Karte hinzu, um zu beginnen
</Text>
<View style={{ marginTop: 16 }}>
<Button
onPress={() => router.push(`/card/create?deckId=${deckId}`)}
variant="primary"
size="sm">
Erste Karte erstellen
</Button>
</View>
</View>
</UICard>
);
}
if (cards.length === 0 && !isLoading) {
return (
<UICard padding="lg" variant="outlined">
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
<Icon name="card-outline" size={48} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginTop: 8, color: colors.mutedForeground }}>Noch keine Karten</Text>
<Text
style={{
marginTop: 4,
textAlign: 'center',
fontSize: 14,
color: colors.mutedForeground,
}}
>
Füge deine erste Karte hinzu, um zu beginnen
</Text>
<View style={{ marginTop: 16 }}>
<Button
onPress={() => router.push(`/card/create?deckId=${deckId}`)}
variant="primary"
size="sm"
>
Erste Karte erstellen
</Button>
</View>
</View>
</UICard>
);
}
return (
<View>
<FlatList
data={displayCards}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<CardItem
card={item}
isCompact={isCompact}
showActions={showActions}
onPress={() => handleCardPress(item)}
onEdit={() => handleEdit(item)}
onDelete={() => handleDelete(item)}
onToggleFavorite={() => handleToggleFavorite(item)}
/>
)}
scrollEnabled={false}
ItemSeparatorComponent={isCompact ? undefined : () => <View style={{ height: 8 }} />}
/>
return (
<View>
<FlatList
data={displayCards}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<CardItem
card={item}
isCompact={isCompact}
showActions={showActions}
onPress={() => handleCardPress(item)}
onEdit={() => handleEdit(item)}
onDelete={() => handleDelete(item)}
onToggleFavorite={() => handleToggleFavorite(item)}
/>
)}
scrollEnabled={false}
ItemSeparatorComponent={isCompact ? undefined : () => <View style={{ height: 8 }} />}
/>
{limit && cards.length > limit && (
<Pressable
onPress={() => router.push(`/deck/${deckId}/cards`)}
style={({ pressed }) => ({ marginTop: 12, opacity: pressed ? 0.7 : 1 })}>
<Text style={{ textAlign: 'center', fontWeight: '500', color: colors.primary }}>
Alle {cards.length} Karten anzeigen
</Text>
</Pressable>
)}
</View>
);
{limit && cards.length > limit && (
<Pressable
onPress={() => router.push(`/deck/${deckId}/cards`)}
style={({ pressed }) => ({ marginTop: 12, opacity: pressed ? 0.7 : 1 })}
>
<Text style={{ textAlign: 'center', fontWeight: '500', color: colors.primary }}>
Alle {cards.length} Karten anzeigen
</Text>
</Pressable>
)}
</View>
);
};

View file

@ -3,165 +3,173 @@ import { View, Text, Pressable } from 'react-native';
import { Icon } from '../ui/Icon';
interface CardType {
id: 'text' | 'flashcard' | 'quiz' | 'mixed';
name: string;
description: string;
icon: string;
color: string;
id: 'text' | 'flashcard' | 'quiz' | 'mixed';
name: string;
description: string;
icon: string;
color: string;
}
const cardTypes: CardType[] = [
{
id: 'text',
name: 'Text',
description: 'Einfache Textkarte mit Formatierung',
icon: 'document-text-outline',
color: 'bg-blue-500',
},
{
id: 'flashcard',
name: 'Flashcard',
description: 'Vorder- und Rückseite zum Lernen',
icon: 'card-outline',
color: 'bg-green-500',
},
{
id: 'quiz',
name: 'Quiz',
description: 'Multiple Choice Frage',
icon: 'help-circle-outline',
color: 'bg-purple-500',
},
{
id: 'mixed',
name: 'Mixed',
description: 'Kombiniert verschiedene Elemente',
icon: 'grid-outline',
color: 'bg-orange-500',
},
{
id: 'text',
name: 'Text',
description: 'Einfache Textkarte mit Formatierung',
icon: 'document-text-outline',
color: 'bg-blue-500',
},
{
id: 'flashcard',
name: 'Flashcard',
description: 'Vorder- und Rückseite zum Lernen',
icon: 'card-outline',
color: 'bg-green-500',
},
{
id: 'quiz',
name: 'Quiz',
description: 'Multiple Choice Frage',
icon: 'help-circle-outline',
color: 'bg-purple-500',
},
{
id: 'mixed',
name: 'Mixed',
description: 'Kombiniert verschiedene Elemente',
icon: 'grid-outline',
color: 'bg-orange-500',
},
];
interface CardTypeSelectorProps {
selectedType: 'text' | 'flashcard' | 'quiz' | 'mixed';
onTypeChange: (type: 'text' | 'flashcard' | 'quiz' | 'mixed') => void;
showDescriptions?: boolean;
layout?: 'grid' | 'list';
compact?: boolean;
selectedType: 'text' | 'flashcard' | 'quiz' | 'mixed';
onTypeChange: (type: 'text' | 'flashcard' | 'quiz' | 'mixed') => void;
showDescriptions?: boolean;
layout?: 'grid' | 'list';
compact?: boolean;
}
export const CardTypeSelector: React.FC<CardTypeSelectorProps> = ({
selectedType,
onTypeChange,
showDescriptions = true,
layout = 'grid',
compact = false,
selectedType,
onTypeChange,
showDescriptions = true,
layout = 'grid',
compact = false,
}) => {
if (compact) {
return (
<View className="flex-row items-center space-x-2">
{cardTypes.map((type) => (
<Pressable
key={type.id}
onPress={() => onTypeChange(type.id)}
className={`
if (compact) {
return (
<View className="flex-row items-center space-x-2">
{cardTypes.map((type) => (
<Pressable
key={type.id}
onPress={() => onTypeChange(type.id)}
className={`
flex-row items-center rounded-lg border px-3 py-2
${selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'}
`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<View className={`h-4 w-4 ${type.color} mr-2 items-center justify-center rounded`}>
<Icon name={type.icon} size={10} color="white" library="Ionicons" />
</View>
<Text
className={`text-sm font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-700'
}`}>
{type.name}
</Text>
</Pressable>
))}
</View>
);
}
if (layout === 'list') {
return (
<View className="space-y-2">
{cardTypes.map((type) => (
<Pressable
key={type.id}
onPress={() => onTypeChange(type.id)}
className={`
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<View className={`h-4 w-4 ${type.color} mr-2 items-center justify-center rounded`}>
<Icon name={type.icon} size={10} color="white" library="Ionicons" />
</View>
<Text
className={`text-sm font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-700'
}`}
>
{type.name}
</Text>
</Pressable>
))}
</View>
);
}
if (layout === 'list') {
return (
<View className="space-y-2">
{cardTypes.map((type) => (
<Pressable
key={type.id}
onPress={() => onTypeChange(type.id)}
className={`
flex-row items-center rounded-lg border-2 p-3
${
selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}
selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}
`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<View className={`h-10 w-10 ${type.color} mr-3 items-center justify-center rounded-lg`}>
<Icon name={type.icon} size={20} color="white" library="Ionicons" />
</View>
<View className="flex-1">
<Text
className={`text-base font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-900'
}`}>
{type.name}
</Text>
{showDescriptions && (
<Text
className={`text-sm ${
selectedType === type.id ? 'text-blue-600' : 'text-gray-500'
}`}>
{type.description}
</Text>
)}
</View>
{selectedType === type.id && (
<Icon name="checkmark-circle" size={24} color="#3B82F6" library="Ionicons" />
)}
</Pressable>
))}
</View>
);
}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<View className={`h-10 w-10 ${type.color} mr-3 items-center justify-center rounded-lg`}>
<Icon name={type.icon} size={20} color="white" library="Ionicons" />
</View>
<View className="flex-1">
<Text
className={`text-base font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-900'
}`}
>
{type.name}
</Text>
{showDescriptions && (
<Text
className={`text-sm ${
selectedType === type.id ? 'text-blue-600' : 'text-gray-500'
}`}
>
{type.description}
</Text>
)}
</View>
{selectedType === type.id && (
<Icon name="checkmark-circle" size={24} color="#3B82F6" library="Ionicons" />
)}
</Pressable>
))}
</View>
);
}
return (
<View className="-mx-2 flex-row flex-wrap">
{cardTypes.map((type) => (
<View key={type.id} className="mb-4 w-1/2 px-2">
<Pressable
onPress={() => onTypeChange(type.id)}
className={`
return (
<View className="-mx-2 flex-row flex-wrap">
{cardTypes.map((type) => (
<View key={type.id} className="mb-4 w-1/2 px-2">
<Pressable
onPress={() => onTypeChange(type.id)}
className={`
items-center rounded-xl border-2 p-4
${
selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}
selectedType === type.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
}
`}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<View className={`h-12 w-12 ${type.color} mb-2 items-center justify-center rounded-lg`}>
<Icon name={type.icon} size={24} color="white" library="Ionicons" />
</View>
<Text
className={`text-center text-base font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-900'
}`}>
{type.name}
</Text>
{showDescriptions && (
<Text
className={`mt-1 text-center text-xs ${
selectedType === type.id ? 'text-blue-600' : 'text-gray-500'
}`}>
{type.description}
</Text>
)}
{selectedType === type.id && (
<View className="absolute right-2 top-2">
<Icon name="checkmark-circle" size={20} color="#3B82F6" library="Ionicons" />
</View>
)}
</Pressable>
</View>
))}
</View>
);
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<View className={`h-12 w-12 ${type.color} mb-2 items-center justify-center rounded-lg`}>
<Icon name={type.icon} size={24} color="white" library="Ionicons" />
</View>
<Text
className={`text-center text-base font-medium ${
selectedType === type.id ? 'text-blue-900' : 'text-gray-900'
}`}
>
{type.name}
</Text>
{showDescriptions && (
<Text
className={`mt-1 text-center text-xs ${
selectedType === type.id ? 'text-blue-600' : 'text-gray-500'
}`}
>
{type.description}
</Text>
)}
{selectedType === type.id && (
<View className="absolute right-2 top-2">
<Icon name="checkmark-circle" size={20} color="#3B82F6" library="Ionicons" />
</View>
)}
</Pressable>
</View>
))}
</View>
);
};

View file

@ -3,285 +3,456 @@ import { View, Pressable, ScrollView } from 'react-native';
import { Icon } from '../ui/Icon';
import { Text } from '../ui/Text';
import {
Card,
TextContent,
FlashcardContent,
QuizContent,
MixedContent,
Card,
TextContent,
FlashcardContent,
QuizContent,
MixedContent,
} from '../../store/cardStore';
import { Button } from '../ui/Button';
import { useThemeColors } from '~/utils/themeUtils';
interface CardViewProps {
card: Card;
mode: 'view' | 'study' | 'edit' | 'preview';
onFlip?: () => void;
onAnswerSelect?: (answerIndex: number) => void;
onEdit?: () => void;
showActions?: boolean;
isFlipped?: boolean;
selectedAnswer?: number;
showFeedback?: boolean;
card: Card;
mode: 'view' | 'study' | 'edit' | 'preview';
onFlip?: () => void;
onAnswerSelect?: (answerIndex: number) => void;
onEdit?: () => void;
showActions?: boolean;
isFlipped?: boolean;
selectedAnswer?: number;
showFeedback?: boolean;
}
export const CardView: React.FC<CardViewProps> = ({
card,
mode,
onFlip,
onAnswerSelect,
onEdit,
showActions = false,
isFlipped = false,
selectedAnswer,
showFeedback = false,
card,
mode,
onFlip,
onAnswerSelect,
onEdit,
showActions = false,
isFlipped = false,
selectedAnswer,
showFeedback = false,
}) => {
const [localFlipped, setLocalFlipped] = useState(false);
const [showHint, setShowHint] = useState(false);
const actuallyFlipped = isFlipped !== undefined ? isFlipped : localFlipped;
const colors = useThemeColors();
const [localFlipped, setLocalFlipped] = useState(false);
const [showHint, setShowHint] = useState(false);
const actuallyFlipped = isFlipped !== undefined ? isFlipped : localFlipped;
const colors = useThemeColors();
const handleFlip = () => {
if (onFlip) {
onFlip();
} else {
setLocalFlipped(!localFlipped);
}
};
const handleFlip = () => {
if (onFlip) {
onFlip();
} else {
setLocalFlipped(!localFlipped);
}
};
const renderTextCard = (content: TextContent) => (
<View style={{ padding: 16 }}>
{card.title && <Text style={{ marginBottom: 12, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>{card.title}</Text>}
<Text style={{ fontSize: 16, lineHeight: 24, color: colors.foreground }}>{content.text}</Text>
</View>
);
const renderTextCard = (content: TextContent) => (
<View style={{ padding: 16 }}>
{card.title && (
<Text
style={{ marginBottom: 12, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}
>
{card.title}
</Text>
)}
<Text style={{ fontSize: 16, lineHeight: 24, color: colors.foreground }}>{content.text}</Text>
</View>
);
const renderFlashcard = (content: FlashcardContent) => (
<View style={{ height: '100%', flexDirection: 'column' }}>
{/* Main Content Area */}
<View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
{!actuallyFlipped ? (
// Front side
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 28, fontWeight: '600', textAlign: 'center', lineHeight: 36, color: colors.foreground }}>
{content.front}
</Text>
const renderFlashcard = (content: FlashcardContent) => (
<View style={{ height: '100%', flexDirection: 'column' }}>
{/* Main Content Area */}
<View style={{ flex: 1, padding: 24, justifyContent: 'center' }}>
{!actuallyFlipped ? (
// Front side
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
<Text
style={{
fontSize: 28,
fontWeight: '600',
textAlign: 'center',
lineHeight: 36,
color: colors.foreground,
}}
>
{content.front}
</Text>
{/* Hint Content - only if shown */}
{content.hint && mode === 'study' && showHint && (
<View style={{ marginTop: 20, width: '100%', borderRadius: 12, backgroundColor: colors.accent, padding: 16 }}>
<Text style={{ fontSize: 15, lineHeight: 22, color: colors.accentForeground }}>
{content.hint}
</Text>
</View>
)}
</View>
) : (
// Back side
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 28, fontWeight: '600', textAlign: 'center', lineHeight: 36, color: colors.foreground }}>
{content.back}
</Text>
</View>
)}
</View>
{/* Hint Content - only if shown */}
{content.hint && mode === 'study' && showHint && (
<View
style={{
marginTop: 20,
width: '100%',
borderRadius: 12,
backgroundColor: colors.accent,
padding: 16,
}}
>
<Text style={{ fontSize: 15, lineHeight: 22, color: colors.accentForeground }}>
{content.hint}
</Text>
</View>
)}
</View>
) : (
// Back side
<View style={{ justifyContent: 'center', alignItems: 'center' }}>
<Text
style={{
fontSize: 28,
fontWeight: '600',
textAlign: 'center',
lineHeight: 36,
color: colors.foreground,
}}
>
{content.back}
</Text>
</View>
)}
</View>
{/* Fixed Hint Bar at Bottom - only on front side */}
{!actuallyFlipped && content.hint && mode === 'study' && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, backgroundColor: colors.card }}>
<Pressable
onPress={() => setShowHint(!showHint)}
style={({ pressed }) => ({
paddingVertical: 18,
paddingHorizontal: 24,
backgroundColor: showHint ? `${colors.accent}20` : 'transparent',
opacity: pressed ? 0.7 : 1,
})}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Icon
name={showHint ? 'bulb' : 'bulb-outline'}
library="Ionicons"
size={20}
color={showHint ? colors.primary : colors.mutedForeground}
/>
<Text style={{ fontSize: 15, fontWeight: '500', color: showHint ? colors.primary : colors.mutedForeground, marginLeft: 6 }}>
{showHint ? 'Hinweis ausblenden' : 'Hinweis anzeigen'}
</Text>
</View>
</Pressable>
</View>
)}
</View>
);
{/* Fixed Hint Bar at Bottom - only on front side */}
{!actuallyFlipped && content.hint && mode === 'study' && (
<View
style={{
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.card,
}}
>
<Pressable
onPress={() => setShowHint(!showHint)}
style={({ pressed }) => ({
paddingVertical: 18,
paddingHorizontal: 24,
backgroundColor: showHint ? `${colors.accent}20` : 'transparent',
opacity: pressed ? 0.7 : 1,
})}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Icon
name={showHint ? 'bulb' : 'bulb-outline'}
library="Ionicons"
size={20}
color={showHint ? colors.primary : colors.mutedForeground}
/>
<Text
style={{
fontSize: 15,
fontWeight: '500',
color: showHint ? colors.primary : colors.mutedForeground,
marginLeft: 6,
}}
>
{showHint ? 'Hinweis ausblenden' : 'Hinweis anzeigen'}
</Text>
</View>
</Pressable>
</View>
)}
</View>
);
const renderQuizCard = (content: QuizContent) => (
<View style={{ padding: 16 }}>
<Text style={{ marginBottom: 16, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>{content.question}</Text>
const renderQuizCard = (content: QuizContent) => (
<View style={{ padding: 16 }}>
<Text
style={{ marginBottom: 16, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}
>
{content.question}
</Text>
<View style={{ gap: 12 }}>
{content.options.map((option, index) => {
const isSelected = selectedAnswer === index;
const isCorrect = index === content.correct_answer;
const showCorrect = showFeedback && isCorrect;
const showIncorrect = showFeedback && isSelected && !isCorrect;
<View style={{ gap: 12 }}>
{content.options.map((option, index) => {
const isSelected = selectedAnswer === index;
const isCorrect = index === content.correct_answer;
const showCorrect = showFeedback && isCorrect;
const showIncorrect = showFeedback && isSelected && !isCorrect;
const getBorderColor = () => {
if (showCorrect) return 'rgb(34, 197, 94)'; // green-500
if (showIncorrect) return colors.destructive;
if (isSelected && !showFeedback) return colors.primary;
return colors.border;
};
const getBorderColor = () => {
if (showCorrect) return 'rgb(34, 197, 94)'; // green-500
if (showIncorrect) return colors.destructive;
if (isSelected && !showFeedback) return colors.primary;
return colors.border;
};
const getBackgroundColor = () => {
if (showCorrect) return 'rgba(34, 197, 94, 0.1)';
if (showIncorrect) return `${colors.destructive}15`;
if (isSelected && !showFeedback) return `${colors.primary}15`;
return colors.surface;
};
const getBackgroundColor = () => {
if (showCorrect) return 'rgba(34, 197, 94, 0.1)';
if (showIncorrect) return `${colors.destructive}15`;
if (isSelected && !showFeedback) return `${colors.primary}15`;
return colors.surface;
};
return (
<Pressable
key={index}
onPress={() => onAnswerSelect?.(index)}
disabled={showFeedback || mode !== 'study'}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
borderWidth: 2,
padding: 12,
borderColor: getBorderColor(),
backgroundColor: getBackgroundColor(),
opacity: pressed && !showFeedback && mode === 'study' ? 0.7 : 1,
})}>
<View
style={{
marginRight: 12,
height: 24,
width: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
borderWidth: 2,
borderColor: getBorderColor(),
backgroundColor: (isSelected || showCorrect) ? getBorderColor() : 'transparent',
}}>
{(isSelected || showCorrect) && (
<Icon
name={showCorrect ? 'checkmark' : showIncorrect ? 'close' : 'checkmark'}
library="Ionicons"
size={16}
color={colors.background}
/>
)}
</View>
<Text
style={{
flex: 1,
fontSize: 16,
color: showCorrect ? 'rgb(34, 197, 94)' : showIncorrect ? colors.destructive : isSelected && !showFeedback ? colors.primary : colors.foreground,
}}>
{option}
</Text>
</Pressable>
);
})}
</View>
return (
<Pressable
key={index}
onPress={() => onAnswerSelect?.(index)}
disabled={showFeedback || mode !== 'study'}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
borderWidth: 2,
padding: 12,
borderColor: getBorderColor(),
backgroundColor: getBackgroundColor(),
opacity: pressed && !showFeedback && mode === 'study' ? 0.7 : 1,
})}
>
<View
style={{
marginRight: 12,
height: 24,
width: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
borderWidth: 2,
borderColor: getBorderColor(),
backgroundColor: isSelected || showCorrect ? getBorderColor() : 'transparent',
}}
>
{(isSelected || showCorrect) && (
<Icon
name={showCorrect ? 'checkmark' : showIncorrect ? 'close' : 'checkmark'}
library="Ionicons"
size={16}
color={colors.background}
/>
)}
</View>
<Text
style={{
flex: 1,
fontSize: 16,
color: showCorrect
? 'rgb(34, 197, 94)'
: showIncorrect
? colors.destructive
: isSelected && !showFeedback
? colors.primary
: colors.foreground,
}}
>
{option}
</Text>
</Pressable>
);
})}
</View>
{showFeedback && content.explanation && (
<View style={{ marginTop: 16, borderRadius: 8, backgroundColor: `${colors.primary}15`, padding: 12 }}>
<Text style={{ marginBottom: 4, fontSize: 14, fontWeight: '500', color: colors.primary }}>Erklärung:</Text>
<Text style={{ fontSize: 14, color: colors.primary }}>{content.explanation}</Text>
</View>
)}
</View>
);
{showFeedback && content.explanation && (
<View
style={{
marginTop: 16,
borderRadius: 8,
backgroundColor: `${colors.primary}15`,
padding: 12,
}}
>
<Text style={{ marginBottom: 4, fontSize: 14, fontWeight: '500', color: colors.primary }}>
Erklärung:
</Text>
<Text style={{ fontSize: 14, color: colors.primary }}>{content.explanation}</Text>
</View>
)}
</View>
);
const renderMixedCard = (content: MixedContent) => (
<View style={{ padding: 16 }}>
{card.title && <Text style={{ marginBottom: 16, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>{card.title}</Text>}
<ScrollView style={{ maxHeight: 384 }}>
{content.blocks.map((block, index) => (
<View key={index} style={{ marginBottom: 16 }}>
{block.type === 'text' && (
<Text style={{ fontSize: 16, lineHeight: 24, color: colors.foreground }}>{block.data.text}</Text>
)}
{block.type === 'image' && (
<View style={{ height: 128, alignItems: 'center', justifyContent: 'center', borderRadius: 8, backgroundColor: colors.muted }}>
<Icon name="image-outline" library="Ionicons" size={32} color={colors.mutedForeground} />
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>
Bild: {block.data.caption || 'Ohne Titel'}
</Text>
</View>
)}
{block.type === 'quiz' && (
<View style={{ borderRadius: 8, borderWidth: 1, borderColor: colors.border, padding: 12 }}>
<Text style={{ marginBottom: 8, fontSize: 14, fontWeight: '500', color: colors.mutedForeground }}>Quiz-Block</Text>
{renderQuizCard({ ...block.data } as QuizContent)}
</View>
)}
{block.type === 'flashcard' && (
<View style={{ borderRadius: 8, borderWidth: 1, borderColor: colors.border, padding: 12 }}>
<Text style={{ marginBottom: 8, fontSize: 14, fontWeight: '500', color: colors.mutedForeground }}>Flashcard-Block</Text>
{renderFlashcard({ ...block.data } as FlashcardContent)}
</View>
)}
</View>
))}
</ScrollView>
</View>
);
const renderMixedCard = (content: MixedContent) => (
<View style={{ padding: 16 }}>
{card.title && (
<Text
style={{ marginBottom: 16, fontSize: 20, fontWeight: 'bold', color: colors.foreground }}
>
{card.title}
</Text>
)}
<ScrollView style={{ maxHeight: 384 }}>
{content.blocks.map((block, index) => (
<View key={index} style={{ marginBottom: 16 }}>
{block.type === 'text' && (
<Text style={{ fontSize: 16, lineHeight: 24, color: colors.foreground }}>
{block.data.text}
</Text>
)}
{block.type === 'image' && (
<View
style={{
height: 128,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
backgroundColor: colors.muted,
}}
>
<Icon
name="image-outline"
library="Ionicons"
size={32}
color={colors.mutedForeground}
/>
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>
Bild: {block.data.caption || 'Ohne Titel'}
</Text>
</View>
)}
{block.type === 'quiz' && (
<View
style={{
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
padding: 12,
}}
>
<Text
style={{
marginBottom: 8,
fontSize: 14,
fontWeight: '500',
color: colors.mutedForeground,
}}
>
Quiz-Block
</Text>
{renderQuizCard({ ...block.data } as QuizContent)}
</View>
)}
{block.type === 'flashcard' && (
<View
style={{
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
padding: 12,
}}
>
<Text
style={{
marginBottom: 8,
fontSize: 14,
fontWeight: '500',
color: colors.mutedForeground,
}}
>
Flashcard-Block
</Text>
{renderFlashcard({ ...block.data } as FlashcardContent)}
</View>
)}
</View>
))}
</ScrollView>
</View>
);
const renderCardContent = () => {
switch (card.card_type) {
case 'text':
return renderTextCard(card.content as TextContent);
case 'flashcard':
return renderFlashcard(card.content as FlashcardContent);
case 'quiz':
return renderQuizCard(card.content as QuizContent);
case 'mixed':
return renderMixedCard(card.content as MixedContent);
default:
return (
<View style={{ padding: 16 }}>
<Text style={{ color: colors.mutedForeground }}>Unbekannter Kartentyp</Text>
</View>
);
}
};
const renderCardContent = () => {
switch (card.card_type) {
case 'text':
return renderTextCard(card.content as TextContent);
case 'flashcard':
return renderFlashcard(card.content as FlashcardContent);
case 'quiz':
return renderQuizCard(card.content as QuizContent);
case 'mixed':
return renderMixedCard(card.content as MixedContent);
default:
return (
<View style={{ padding: 16 }}>
<Text style={{ color: colors.mutedForeground }}>Unbekannter Kartentyp</Text>
</View>
);
}
};
return (
<View style={{ height: '100%', width: '100%', borderRadius: 12, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.card, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 2 }}>
{/* Card Header */}
{showActions && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: colors.border, padding: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ marginRight: 8, height: 8, width: 8, borderRadius: 4, backgroundColor: colors.primary }} />
<Text style={{ fontSize: 14, fontWeight: '500', textTransform: 'capitalize', color: colors.mutedForeground }}>{card.card_type}</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{card.is_favorite && <Icon name="heart" library="Ionicons" size={16} color={colors.destructive} />}
{onEdit && (
<Pressable onPress={onEdit} style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="create-outline" library="Ionicons" size={20} color={colors.mutedForeground} />
</Pressable>
)}
</View>
</View>
)}
return (
<View
style={{
height: '100%',
width: '100%',
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.card,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
}}
>
{/* Card Header */}
{showActions && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: colors.border,
padding: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
marginRight: 8,
height: 8,
width: 8,
borderRadius: 4,
backgroundColor: colors.primary,
}}
/>
<Text
style={{
fontSize: 14,
fontWeight: '500',
textTransform: 'capitalize',
color: colors.mutedForeground,
}}
>
{card.card_type}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
{card.is_favorite && (
<Icon name="heart" library="Ionicons" size={16} color={colors.destructive} />
)}
{onEdit && (
<Pressable onPress={onEdit} style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon
name="create-outline"
library="Ionicons"
size={20}
color={colors.mutedForeground}
/>
</Pressable>
)}
</View>
</View>
)}
{/* Card Content */}
{renderCardContent()}
{/* Card Content */}
{renderCardContent()}
{/* Card Footer */}
{mode === 'view' && (
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>Position {card.position}</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>Version {card.version}</Text>
</View>
</View>
)}
</View>
);
{/* Card Footer */}
{mode === 'view' && (
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
Position {card.position}
</Text>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
Version {card.version}
</Text>
</View>
</View>
)}
</View>
);
};

View file

@ -6,129 +6,198 @@ import { Deck } from '../../store/deckStore';
import { useThemeColors } from '~/utils/themeUtils';
interface DeckCardProps {
deck: Deck;
onPress: () => void;
showProgress?: boolean;
isCompact?: boolean;
deck: Deck;
onPress: () => void;
showProgress?: boolean;
isCompact?: boolean;
}
export const DeckCard: React.FC<DeckCardProps> = ({
deck,
onPress,
showProgress = false,
isCompact = false,
deck,
onPress,
showProgress = false,
isCompact = false,
}) => {
const isFavorite = deck.metadata?.is_favorite || false;
const colors = useThemeColors();
const isFavorite = deck.metadata?.is_favorite || false;
const colors = useThemeColors();
if (isCompact) {
return (
<Pressable onPress={onPress} style={({ pressed }) => ({
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
padding: 16,
marginBottom: 12,
opacity: pressed ? 0.95 : 1,
})}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text style={{ fontWeight: '600', color: colors.foreground, fontSize: 16 }} numberOfLines={1}>
{deck.title}
</Text>
<View style={{ marginTop: 4, flexDirection: 'row', alignItems: 'center' }}>
<Icon name="card-outline" size={14} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginLeft: 4, color: colors.mutedForeground, fontSize: 12 }}>
{deck.card_count || 0} Karten
</Text>
{deck.is_public && (
<>
<Text style={{ marginHorizontal: 8, color: colors.mutedForeground }}></Text>
<Icon name="globe-outline" size={14} color={colors.mutedForeground} library="Ionicons" />
<Text style={{ marginLeft: 4, color: colors.mutedForeground, fontSize: 12 }}>
Öffentlich
</Text>
</>
)}
</View>
</View>
<Icon name="chevron-forward" size={20} color={colors.mutedForeground} library="Ionicons" />
</View>
</Pressable>
);
}
if (isCompact) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
padding: 16,
marginBottom: 12,
opacity: pressed ? 0.95 : 1,
})}
>
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<View style={{ flex: 1 }}>
<Text
style={{ fontWeight: '600', color: colors.foreground, fontSize: 16 }}
numberOfLines={1}
>
{deck.title}
</Text>
<View style={{ marginTop: 4, flexDirection: 'row', alignItems: 'center' }}>
<Icon
name="card-outline"
size={14}
color={colors.mutedForeground}
library="Ionicons"
/>
<Text style={{ marginLeft: 4, color: colors.mutedForeground, fontSize: 12 }}>
{deck.card_count || 0} Karten
</Text>
{deck.is_public && (
<>
<Text style={{ marginHorizontal: 8, color: colors.mutedForeground }}></Text>
<Icon
name="globe-outline"
size={14}
color={colors.mutedForeground}
library="Ionicons"
/>
<Text style={{ marginLeft: 4, color: colors.mutedForeground, fontSize: 12 }}>
Öffentlich
</Text>
</>
)}
</View>
</View>
<Icon
name="chevron-forward"
size={20}
color={colors.mutedForeground}
library="Ionicons"
/>
</View>
</Pressable>
);
}
return (
<View style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
overflow: 'hidden',
}}>
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.95 : 1 })}>
{deck.cover_image_url ? (
<Image source={{ uri: deck.cover_image_url }} style={{ height: 140, width: '100%', borderTopLeftRadius: 16, borderTopRightRadius: 16 }} resizeMode="cover" />
) : (
<View style={{
height: 140,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: `${colors.primary}15`,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}>
<Icon name="albums-outline" size={40} color={colors.primary} library="Ionicons" />
</View>
)}
return (
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
overflow: 'hidden',
}}
>
<Pressable onPress={onPress} style={({ pressed }) => ({ opacity: pressed ? 0.95 : 1 })}>
{deck.cover_image_url ? (
<Image
source={{ uri: deck.cover_image_url }}
style={{
height: 140,
width: '100%',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
resizeMode="cover"
/>
) : (
<View
style={{
height: 140,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: `${colors.primary}15`,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<Icon name="albums-outline" size={40} color={colors.primary} library="Ionicons" />
</View>
)}
<View style={{ padding: 16 }}>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 17, fontWeight: '600', color: colors.foreground, marginBottom: 4 }} numberOfLines={1}>
{deck.title}
</Text>
{deck.description && (
<Text style={{ color: colors.mutedForeground, fontSize: 14, lineHeight: 20 }} numberOfLines={2}>
{deck.description}
</Text>
)}
</View>
{isFavorite && (
<Icon
name="heart"
size={18}
color={colors.destructive}
style={{ marginLeft: 12, marginTop: 2 }}
library="Ionicons"
/>
)}
</View>
<View style={{ padding: 16 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 17,
fontWeight: '600',
color: colors.foreground,
marginBottom: 4,
}}
numberOfLines={1}
>
{deck.title}
</Text>
{deck.description && (
<Text
style={{ color: colors.mutedForeground, fontSize: 14, lineHeight: 20 }}
numberOfLines={2}
>
{deck.description}
</Text>
)}
</View>
{isFavorite && (
<Icon
name="heart"
size={18}
color={colors.destructive}
style={{ marginLeft: 12, marginTop: 2 }}
library="Ionicons"
/>
)}
</View>
{showProgress && (
<View style={{ marginTop: 16 }}>
<View style={{ marginBottom: 6, flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 12, fontWeight: '500', color: colors.mutedForeground }}>
Fortschritt
</Text>
<Text style={{ fontSize: 12, fontWeight: '500', color: colors.mutedForeground }}>
0%
</Text>
</View>
<View style={{ height: 6, overflow: 'hidden', borderRadius: 3, backgroundColor: `${colors.muted}60` }}>
<View style={{ height: '100%', borderRadius: 3, backgroundColor: colors.primary, width: '0%' }} />
</View>
</View>
)}
</View>
</Pressable>
</View>
);
{showProgress && (
<View style={{ marginTop: 16 }}>
<View
style={{ marginBottom: 6, flexDirection: 'row', justifyContent: 'space-between' }}
>
<Text style={{ fontSize: 12, fontWeight: '500', color: colors.mutedForeground }}>
Fortschritt
</Text>
<Text style={{ fontSize: 12, fontWeight: '500', color: colors.mutedForeground }}>
0%
</Text>
</View>
<View
style={{
height: 6,
overflow: 'hidden',
borderRadius: 3,
backgroundColor: `${colors.muted}60`,
}}
>
<View
style={{
height: '100%',
borderRadius: 3,
backgroundColor: colors.primary,
width: '0%',
}}
/>
</View>
</View>
)}
</View>
</Pressable>
</View>
);
};

View file

@ -8,97 +8,139 @@ import { router } from 'expo-router';
import { useThemeColors } from '~/utils/themeUtils';
interface DeckProgressCardProps {
progress: DeckProgress;
progress: DeckProgress;
}
export const DeckProgressCard: React.FC<DeckProgressCardProps> = ({ progress }) => {
const colors = useThemeColors();
const getMasteryColor = (percentage: number) => {
if (percentage >= 80) return '#16a34a'; // green-600
if (percentage >= 60) return '#2563eb'; // blue-600
if (percentage >= 40) return '#ca8a04'; // yellow-600
if (percentage >= 20) return '#ea580c'; // orange-600
return colors.mutedForeground;
};
const colors = useThemeColors();
const getMasteryIcon = (percentage: number) => {
if (percentage >= 80) return 'trophy';
if (percentage >= 60) return 'medal';
if (percentage >= 40) return 'ribbon';
if (percentage >= 20) return 'school';
return 'book';
};
const getMasteryColor = (percentage: number) => {
if (percentage >= 80) return '#16a34a'; // green-600
if (percentage >= 60) return '#2563eb'; // blue-600
if (percentage >= 40) return '#ca8a04'; // yellow-600
if (percentage >= 20) return '#ea580c'; // orange-600
return colors.mutedForeground;
};
return (
<Pressable
onPress={() => router.push(`/deck/${progress.deck_id}`)}
style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Card padding="md" variant="elevated">
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ flex: 1, fontSize: 16, fontWeight: '600', color: colors.foreground }} numberOfLines={1}>
{progress.deck_name}
</Text>
<Icon
name={getMasteryIcon(progress.completion_percentage)}
library="Ionicons"
size={20}
color={getMasteryColor(progress.completion_percentage)}
/>
</View>
const getMasteryIcon = (percentage: number) => {
if (percentage >= 80) return 'trophy';
if (percentage >= 60) return 'medal';
if (percentage >= 40) return 'ribbon';
if (percentage >= 20) return 'school';
return 'book';
};
{/* Progress Bar */}
<View style={{ marginTop: 8, height: 8, overflow: 'hidden', borderRadius: 9999, backgroundColor: colors.muted }}>
<View
style={{
height: '100%',
backgroundColor: '#3b82f6',
width: `${progress.completion_percentage}%`
}}
/>
</View>
return (
<Pressable
onPress={() => router.push(`/deck/${progress.deck_id}`)}
style={({ pressed }) => pressed && { opacity: 0.7 }}
>
<Card padding="md" variant="elevated">
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text
style={{ flex: 1, fontSize: 16, fontWeight: '600', color: colors.foreground }}
numberOfLines={1}
>
{progress.deck_name}
</Text>
<Icon
name={getMasteryIcon(progress.completion_percentage)}
library="Ionicons"
size={20}
color={getMasteryColor(progress.completion_percentage)}
/>
</View>
{/* Stats */}
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: '#10b981' }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.mastered_cards} gemeistert
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: '#eab308' }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.learning_cards} lernen
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: colors.muted }} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.new_cards} neu
</Text>
</View>
</View>
{/* Progress Bar */}
<View
style={{
marginTop: 8,
height: 8,
overflow: 'hidden',
borderRadius: 9999,
backgroundColor: colors.muted,
}}
>
<View
style={{
height: '100%',
backgroundColor: '#3b82f6',
width: `${progress.completion_percentage}%`,
}}
/>
</View>
<Text
style={{ fontSize: 14, fontWeight: 'bold', color: getMasteryColor(progress.completion_percentage) }}>
{progress.completion_percentage}%
</Text>
</View>
{/* Stats */}
<View
style={{
marginTop: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: '#10b981' }}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.mastered_cards} gemeistert
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: '#eab308' }}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.learning_cards} lernen
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 8,
width: 8,
borderRadius: 9999,
backgroundColor: colors.muted,
}}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
{progress.new_cards} neu
</Text>
</View>
</View>
{/* Ease Factor */}
<View style={{ marginTop: 4, flexDirection: 'row', alignItems: 'center' }}>
<Icon name="speedometer-outline" library="Ionicons" size={12} color={colors.mutedForeground} />
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
Schwierigkeit: {progress.average_ease_factor.toFixed(1)}
</Text>
</View>
</View>
</View>
</Card>
</Pressable>
);
<Text
style={{
fontSize: 14,
fontWeight: 'bold',
color: getMasteryColor(progress.completion_percentage),
}}
>
{progress.completion_percentage}%
</Text>
</View>
{/* Ease Factor */}
<View style={{ marginTop: 4, flexDirection: 'row', alignItems: 'center' }}>
<Icon
name="speedometer-outline"
library="Ionicons"
size={12}
color={colors.mutedForeground}
/>
<Text style={{ marginLeft: 4, fontSize: 12, color: colors.mutedForeground }}>
Schwierigkeit: {progress.average_ease_factor.toFixed(1)}
</Text>
</View>
</View>
</View>
</Card>
</Pressable>
);
};

View file

@ -5,129 +5,129 @@ import { DailyProgress } from '../../store/progressStore';
import { useThemeColors } from '~/utils/themeUtils';
interface HeatmapCalendarProps {
data: Map<string, DailyProgress>;
data: Map<string, DailyProgress>;
}
export const HeatmapCalendar: React.FC<HeatmapCalendarProps> = ({ data }) => {
const colors = useThemeColors();
// Generate grid for last 12 weeks
const weeks = 12;
const daysPerWeek = 7;
const today = new Date();
const grid: (DailyProgress | null)[][] = [];
const colors = useThemeColors();
// Start from 12 weeks ago
const startDate = new Date(today);
startDate.setDate(today.getDate() - weeks * 7 + (7 - today.getDay()));
// Generate grid for last 12 weeks
const weeks = 12;
const daysPerWeek = 7;
const today = new Date();
const grid: (DailyProgress | null)[][] = [];
// Generate grid
for (let week = 0; week < weeks; week++) {
const weekData: (DailyProgress | null)[] = [];
// Start from 12 weeks ago
const startDate = new Date(today);
startDate.setDate(today.getDate() - weeks * 7 + (7 - today.getDay()));
for (let day = 0; day < daysPerWeek; day++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + week * 7 + day);
// Generate grid
for (let week = 0; week < weeks; week++) {
const weekData: (DailyProgress | null)[] = [];
if (currentDate <= today) {
const dateStr = currentDate.toISOString().split('T')[0];
weekData.push(data.get(dateStr) || null);
} else {
weekData.push(null);
}
}
for (let day = 0; day < daysPerWeek; day++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + week * 7 + day);
grid.push(weekData);
}
if (currentDate <= today) {
const dateStr = currentDate.toISOString().split('T')[0];
weekData.push(data.get(dateStr) || null);
} else {
weekData.push(null);
}
}
const getIntensityColor = (progress: DailyProgress | null) => {
if (!progress || progress.cards_studied === 0) return colors.muted;
if (progress.cards_studied <= 5) return '#bbf7d0'; // green-200
if (progress.cards_studied <= 15) return '#4ade80'; // green-400
if (progress.cards_studied <= 30) return '#22c55e'; // green-500
return '#16a34a'; // green-600
};
grid.push(weekData);
}
const weekDays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const months = [
'Jan',
'Feb',
'Mär',
'Apr',
'Mai',
'Jun',
'Jul',
'Aug',
'Sep',
'Okt',
'Nov',
'Dez',
];
const getIntensityColor = (progress: DailyProgress | null) => {
if (!progress || progress.cards_studied === 0) return colors.muted;
if (progress.cards_studied <= 5) return '#bbf7d0'; // green-200
if (progress.cards_studied <= 15) return '#4ade80'; // green-400
if (progress.cards_studied <= 30) return '#22c55e'; // green-500
return '#16a34a'; // green-600
};
// Calculate which months to show
const monthLabels: { month: string; week: number }[] = [];
let lastMonth = -1;
const weekDays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const months = [
'Jan',
'Feb',
'Mär',
'Apr',
'Mai',
'Jun',
'Jul',
'Aug',
'Sep',
'Okt',
'Nov',
'Dez',
];
for (let week = 0; week < weeks; week++) {
const weekStart = new Date(startDate);
weekStart.setDate(startDate.getDate() + week * 7);
const month = weekStart.getMonth();
// Calculate which months to show
const monthLabels: { month: string; week: number }[] = [];
let lastMonth = -1;
if (month !== lastMonth) {
monthLabels.push({ month: months[month], week });
lastMonth = month;
}
}
for (let week = 0; week < weeks; week++) {
const weekStart = new Date(startDate);
weekStart.setDate(startDate.getDate() + week * 7);
const month = weekStart.getMonth();
return (
<View>
{/* Month labels */}
<View style={{ marginBottom: 8, flexDirection: 'row' }}>
<View style={{ width: 32 }} />
<View style={{ flex: 1, flexDirection: 'row' }}>
{monthLabels.map((label, index) => (
<View key={index} style={{ flex: 1, marginLeft: label.week * 12 }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
{label.month}
</Text>
</View>
))}
</View>
</View>
if (month !== lastMonth) {
monthLabels.push({ month: months[month], week });
lastMonth = month;
}
}
{/* Calendar grid */}
<View style={{ flexDirection: 'row' }}>
{/* Week day labels */}
<View style={{ marginRight: 8 }}>
{weekDays.map((day, index) => (
<View key={index} style={{ height: 12, justifyContent: 'center', marginBottom: 2 }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
{index % 2 === 1 ? day : ''}
</Text>
</View>
))}
</View>
return (
<View>
{/* Month labels */}
<View style={{ marginBottom: 8, flexDirection: 'row' }}>
<View style={{ width: 32 }} />
<View style={{ flex: 1, flexDirection: 'row' }}>
{monthLabels.map((label, index) => (
<View key={index} style={{ flex: 1, marginLeft: label.week * 12 }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
{label.month}
</Text>
</View>
))}
</View>
</View>
{/* Heatmap grid */}
<View style={{ flex: 1, flexDirection: 'row' }}>
{grid.map((week, weekIndex) => (
<View key={weekIndex} style={{ marginRight: 4 }}>
{week.map((day, dayIndex) => (
<View
key={dayIndex}
style={{
height: 12,
width: 12,
borderRadius: 2,
backgroundColor: day === null ? 'transparent' : getIntensityColor(day),
marginBottom: 2
}}
/>
))}
</View>
))}
</View>
</View>
</View>
);
{/* Calendar grid */}
<View style={{ flexDirection: 'row' }}>
{/* Week day labels */}
<View style={{ marginRight: 8 }}>
{weekDays.map((day, index) => (
<View key={index} style={{ height: 12, justifyContent: 'center', marginBottom: 2 }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
{index % 2 === 1 ? day : ''}
</Text>
</View>
))}
</View>
{/* Heatmap grid */}
<View style={{ flex: 1, flexDirection: 'row' }}>
{grid.map((week, weekIndex) => (
<View key={weekIndex} style={{ marginRight: 4 }}>
{week.map((day, dayIndex) => (
<View
key={dayIndex}
style={{
height: 12,
width: 12,
borderRadius: 2,
backgroundColor: day === null ? 'transparent' : getIntensityColor(day),
marginBottom: 2,
}}
/>
))}
</View>
))}
</View>
</View>
</View>
);
};

View file

@ -5,140 +5,159 @@ import { useProgressStore } from '../../store/progressStore';
import { useThemeColors } from '~/utils/themeUtils';
interface ProgressChartProps {
type: 'accuracy' | 'cards' | 'time';
period: 'week' | 'month' | 'year';
type: 'accuracy' | 'cards' | 'time';
period: 'week' | 'month' | 'year';
}
export const ProgressChart: React.FC<ProgressChartProps> = ({ type, period }) => {
const { getChartData } = useProgressStore();
const data = getChartData(type);
const colors = useThemeColors();
const { getChartData } = useProgressStore();
const data = getChartData(type);
const colors = useThemeColors();
if (data.length === 0) {
return (
<View style={{ height: 192, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.mutedForeground }}>Keine Daten verfügbar</Text>
</View>
);
}
if (data.length === 0) {
return (
<View style={{ height: 192, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: colors.mutedForeground }}>Keine Daten verfügbar</Text>
</View>
);
}
const getYLabel = () => {
switch (type) {
case 'accuracy':
return 'Genauigkeit (%)';
case 'cards':
return 'Karten';
case 'time':
return 'Minuten';
default:
return '';
}
};
const getYLabel = () => {
switch (type) {
case 'accuracy':
return 'Genauigkeit (%)';
case 'cards':
return 'Karten';
case 'time':
return 'Minuten';
default:
return '';
}
};
const getColor = () => {
switch (type) {
case 'accuracy':
return '#10B981'; // green
case 'cards':
return '#3B82F6'; // blue
case 'time':
return '#F59E0B'; // amber
default:
return '#6B7280';
}
};
const getColor = () => {
switch (type) {
case 'accuracy':
return '#10B981'; // green
case 'cards':
return '#3B82F6'; // blue
case 'time':
return '#F59E0B'; // amber
default:
return '#6B7280';
}
};
// Get max value for scaling
const maxValue = Math.max(...data.map((d) => d.value));
const minValue = Math.min(...data.map((d) => d.value));
// Get max value for scaling
const maxValue = Math.max(...data.map((d) => d.value));
const minValue = Math.min(...data.map((d) => d.value));
// Simple bar chart implementation
return (
<View>
<View style={{ marginBottom: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text variant="caption" style={{ fontWeight: '500', color: colors.foreground }}>
{getYLabel()}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: getColor() }} />
<Text variant="caption" style={{ marginLeft: 4, color: colors.mutedForeground }}>
Ø {Math.round(data.reduce((sum, d) => sum + d.value, 0) / data.length)}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
</View>
// Simple bar chart implementation
return (
<View>
<View
style={{
marginBottom: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Text variant="caption" style={{ fontWeight: '500', color: colors.foreground }}>
{getYLabel()}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ height: 8, width: 8, borderRadius: 9999, backgroundColor: getColor() }} />
<Text variant="caption" style={{ marginLeft: 4, color: colors.mutedForeground }}>
Ø {Math.round(data.reduce((sum, d) => sum + d.value, 0) / data.length)}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
</View>
{/* Simple Bar Chart */}
<View style={{
height: 192,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderLeftWidth: 1,
borderLeftColor: colors.border
}}>
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: 4 }}>
{data.slice(-7).map((item, index) => {
const height = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
return (
<View key={index} style={{ flex: 1, alignItems: 'center' }}>
<View
style={{
marginHorizontal: 2,
width: '100%',
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
height: `${height}%`,
backgroundColor: getColor(),
opacity: 0.8,
}}
/>
<Text variant="small" style={{ marginTop: 4, color: colors.mutedForeground }}>
{item.label}
</Text>
</View>
);
})}
</View>
</View>
{/* Simple Bar Chart */}
<View
style={{
height: 192,
borderBottomWidth: 1,
borderBottomColor: colors.border,
borderLeftWidth: 1,
borderLeftColor: colors.border,
}}
>
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
paddingHorizontal: 4,
}}
>
{data.slice(-7).map((item, index) => {
const height = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
return (
<View key={index} style={{ flex: 1, alignItems: 'center' }}>
<View
style={{
marginHorizontal: 2,
width: '100%',
borderTopLeftRadius: 4,
borderTopRightRadius: 4,
height: `${height}%`,
backgroundColor: getColor(),
opacity: 0.8,
}}
/>
<Text variant="small" style={{ marginTop: 4, color: colors.mutedForeground }}>
{item.label}
</Text>
</View>
);
})}
</View>
</View>
{/* Summary Stats */}
<View style={{
marginTop: 8,
flexDirection: 'row',
justifyContent: 'space-around',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 8
}}>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Min
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{Math.min(...data.map((d) => d.value))}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Max
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{Math.max(...data.map((d) => d.value))}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Gesamt
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{type === 'accuracy'
? `${Math.round(data.reduce((sum, d) => sum + d.value, 0) / data.length)}%`
: data.reduce((sum, d) => sum + d.value, 0)}
</Text>
</View>
</View>
</View>
);
{/* Summary Stats */}
<View
style={{
marginTop: 8,
flexDirection: 'row',
justifyContent: 'space-around',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 8,
}}
>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Min
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{Math.min(...data.map((d) => d.value))}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Max
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{Math.max(...data.map((d) => d.value))}
{type === 'accuracy' ? '%' : ''}
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Gesamt
</Text>
<Text variant="caption" style={{ fontWeight: '600', color: colors.foreground }}>
{type === 'accuracy'
? `${Math.round(data.reduce((sum, d) => sum + d.value, 0) / data.length)}%`
: data.reduce((sum, d) => sum + d.value, 0)}
</Text>
</View>
</View>
</View>
);
};

View file

@ -7,195 +7,207 @@ import { StreakInfo } from '../../store/progressStore';
import { useThemeColors } from '~/utils/themeUtils';
interface StreakCardProps {
streakInfo: StreakInfo | null;
streakInfo: StreakInfo | null;
}
export const StreakCard: React.FC<StreakCardProps> = ({ streakInfo }) => {
const colors = useThemeColors();
if (!streakInfo) {
return (
<Card padding="lg" variant="elevated">
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View>
<Text variant="caption" style={{ color: colors.mutedForeground }}>
Lernstreak
</Text>
<Text variant="h2" style={{ color: colors.foreground }}>
0 Tage
</Text>
</View>
<View style={{
height: 64,
width: 64,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
backgroundColor: colors.muted
}}>
<Text style={{ fontSize: 24 }}>💤</Text>
</View>
</View>
<Text variant="caption" style={{ marginTop: 8, color: colors.mutedForeground }}>
Starte jetzt deine erste Lernsession!
</Text>
</Card>
);
}
const colors = useThemeColors();
const getStreakEmoji = (streak: number) => {
if (streak === 0) return '💤';
if (streak < 3) return '✨';
if (streak < 7) return '🔥';
if (streak < 14) return '💪';
if (streak < 30) return '🚀';
if (streak < 60) return '⭐';
if (streak < 100) return '🏆';
return '👑';
};
if (!streakInfo) {
return (
<Card padding="lg" variant="elevated">
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<View>
<Text variant="caption" style={{ color: colors.mutedForeground }}>
Lernstreak
</Text>
<Text variant="h2" style={{ color: colors.foreground }}>
0 Tage
</Text>
</View>
<View
style={{
height: 64,
width: 64,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
backgroundColor: colors.muted,
}}
>
<Text style={{ fontSize: 24 }}>💤</Text>
</View>
</View>
<Text variant="caption" style={{ marginTop: 8, color: colors.mutedForeground }}>
Starte jetzt deine erste Lernsession!
</Text>
</Card>
);
}
const getMotivationalMessage = (streak: number) => {
if (streak === 0) return 'Zeit wieder zu lernen!';
if (streak === 1) return 'Guter Start! Bleib dran!';
if (streak < 3) return 'Weiter so! Baue eine Gewohnheit auf!';
if (streak < 7) return 'Fantastisch! Fast eine Woche!';
if (streak < 14) return 'Beeindruckend! Du bist auf Kurs!';
if (streak < 30) return 'Unglaublich! Ein Monat ist in Sicht!';
if (streak < 60) return 'Meisterhaft! Du bist nicht zu stoppen!';
if (streak < 100) return 'Legendär! 100 Tage sind nah!';
return 'Unbesiegbar! Du bist eine Lernmaschine!';
};
const getStreakEmoji = (streak: number) => {
if (streak === 0) return '💤';
if (streak < 3) return '✨';
if (streak < 7) return '🔥';
if (streak < 14) return '💪';
if (streak < 30) return '🚀';
if (streak < 60) return '⭐';
if (streak < 100) return '🏆';
return '👑';
};
const daysSinceLastStudy = streakInfo.last_study_date
? Math.floor((new Date().getTime() - new Date(streakInfo.last_study_date).getTime()) / 86400000)
: 999;
const getMotivationalMessage = (streak: number) => {
if (streak === 0) return 'Zeit wieder zu lernen!';
if (streak === 1) return 'Guter Start! Bleib dran!';
if (streak < 3) return 'Weiter so! Baue eine Gewohnheit auf!';
if (streak < 7) return 'Fantastisch! Fast eine Woche!';
if (streak < 14) return 'Beeindruckend! Du bist auf Kurs!';
if (streak < 30) return 'Unglaublich! Ein Monat ist in Sicht!';
if (streak < 60) return 'Meisterhaft! Du bist nicht zu stoppen!';
if (streak < 100) return 'Legendär! 100 Tage sind nah!';
return 'Unbesiegbar! Du bist eine Lernmaschine!';
};
const isStreakActive = daysSinceLastStudy <= 1;
const daysSinceLastStudy = streakInfo.last_study_date
? Math.floor((new Date().getTime() - new Date(streakInfo.last_study_date).getTime()) / 86400000)
: 999;
return (
<Card padding="lg" variant="elevated">
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text variant="caption" style={{ color: colors.mutedForeground }}>
Aktueller Streak
</Text>
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text
variant="h2"
style={{ color: isStreakActive ? '#ea580c' : colors.mutedForeground }}>
{streakInfo.current_streak}
</Text>
<Text variant="h4" style={{ marginLeft: 8, color: colors.mutedForeground }}>
{streakInfo.current_streak === 1 ? 'Tag' : 'Tage'}
</Text>
</View>
<Text variant="caption" style={{ marginTop: 4, color: colors.mutedForeground }}>
{getMotivationalMessage(streakInfo.current_streak)}
</Text>
</View>
const isStreakActive = daysSinceLastStudy <= 1;
<View style={{ alignItems: 'center' }}>
<View
style={{
height: 64,
width: 64,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
backgroundColor: isStreakActive ? '#fed7aa' : colors.muted
}}>
<Text style={{ fontSize: 30 }}>{getStreakEmoji(streakInfo.current_streak)}</Text>
</View>
{!isStreakActive && daysSinceLastStudy > 1 && (
<Text variant="small" style={{ marginTop: 4, color: '#ef4444' }}>
{daysSinceLastStudy}d inaktiv
</Text>
)}
</View>
</View>
return (
<Card padding="lg" variant="elevated">
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text variant="caption" style={{ color: colors.mutedForeground }}>
Aktueller Streak
</Text>
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text
variant="h2"
style={{ color: isStreakActive ? '#ea580c' : colors.mutedForeground }}
>
{streakInfo.current_streak}
</Text>
<Text variant="h4" style={{ marginLeft: 8, color: colors.mutedForeground }}>
{streakInfo.current_streak === 1 ? 'Tag' : 'Tage'}
</Text>
</View>
<Text variant="caption" style={{ marginTop: 4, color: colors.mutedForeground }}>
{getMotivationalMessage(streakInfo.current_streak)}
</Text>
</View>
{/* Stats Row */}
<View style={{
marginTop: 16,
flexDirection: 'row',
justifyContent: 'space-around',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 12
}}>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.longest_streak}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Längster
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.total_study_days}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Gesamt
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.last_study_date
? new Date(streakInfo.last_study_date).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
})
: '-'}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Letztes Mal
</Text>
</View>
</View>
<View style={{ alignItems: 'center' }}>
<View
style={{
height: 64,
width: 64,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
backgroundColor: isStreakActive ? '#fed7aa' : colors.muted,
}}
>
<Text style={{ fontSize: 30 }}>{getStreakEmoji(streakInfo.current_streak)}</Text>
</View>
{!isStreakActive && daysSinceLastStudy > 1 && (
<Text variant="small" style={{ marginTop: 4, color: '#ef4444' }}>
{daysSinceLastStudy}d inaktiv
</Text>
)}
</View>
</View>
{/* Progress to next milestone */}
{streakInfo.current_streak > 0 && (
<View style={{ marginTop: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Nächster Meilenstein
</Text>
<Text variant="small" style={{ fontWeight: '500', color: colors.foreground }}>
{streakInfo.current_streak < 7
? `7 Tage (noch ${7 - streakInfo.current_streak})`
: streakInfo.current_streak < 30
? `30 Tage (noch ${30 - streakInfo.current_streak})`
: streakInfo.current_streak < 100
? `100 Tage (noch ${100 - streakInfo.current_streak})`
: '365 Tage'}
</Text>
</View>
<View style={{
marginTop: 4,
height: 8,
overflow: 'hidden',
borderRadius: 9999,
backgroundColor: colors.muted
}}>
<View
style={{
height: '100%',
backgroundColor: '#fb923c',
width: `${
streakInfo.current_streak < 7
? (streakInfo.current_streak / 7) * 100
: streakInfo.current_streak < 30
? (streakInfo.current_streak / 30) * 100
: streakInfo.current_streak < 100
? (streakInfo.current_streak / 100) * 100
: 100
}%`,
}}
/>
</View>
</View>
)}
</Card>
);
{/* Stats Row */}
<View
style={{
marginTop: 16,
flexDirection: 'row',
justifyContent: 'space-around',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 12,
}}
>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.longest_streak}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Längster
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.total_study_days}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Gesamt
</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text variant="h4" style={{ fontWeight: '600', color: colors.foreground }}>
{streakInfo.last_study_date
? new Date(streakInfo.last_study_date).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
})
: '-'}
</Text>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Letztes Mal
</Text>
</View>
</View>
{/* Progress to next milestone */}
{streakInfo.current_streak > 0 && (
<View style={{ marginTop: 12 }}>
<View
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}
>
<Text variant="small" style={{ color: colors.mutedForeground }}>
Nächster Meilenstein
</Text>
<Text variant="small" style={{ fontWeight: '500', color: colors.foreground }}>
{streakInfo.current_streak < 7
? `7 Tage (noch ${7 - streakInfo.current_streak})`
: streakInfo.current_streak < 30
? `30 Tage (noch ${30 - streakInfo.current_streak})`
: streakInfo.current_streak < 100
? `100 Tage (noch ${100 - streakInfo.current_streak})`
: '365 Tage'}
</Text>
</View>
<View
style={{
marginTop: 4,
height: 8,
overflow: 'hidden',
borderRadius: 9999,
backgroundColor: colors.muted,
}}
>
<View
style={{
height: '100%',
backgroundColor: '#fb923c',
width: `${
streakInfo.current_streak < 7
? (streakInfo.current_streak / 7) * 100
: streakInfo.current_streak < 30
? (streakInfo.current_streak / 30) * 100
: streakInfo.current_streak < 100
? (streakInfo.current_streak / 100) * 100
: 100
}%`,
}}
/>
</View>
</View>
)}
</Card>
);
};

View file

@ -9,152 +9,197 @@ import { useThemeColors } from '~/utils/themeUtils';
export type StudyMode = 'all' | 'new' | 'review' | 'favorites' | 'random';
interface StudyModeSelectorProps {
visible: boolean;
onClose: () => void;
onSelectMode: (mode: StudyMode) => void;
cardStats?: {
total: number;
favorites: number;
new: number;
review: number;
};
visible: boolean;
onClose: () => void;
onSelectMode: (mode: StudyMode) => void;
cardStats?: {
total: number;
favorites: number;
new: number;
review: number;
};
}
interface StudyModeOption {
id: StudyMode;
name: string;
description: string;
icon: string;
getColor: (colors: ReturnType<typeof useThemeColors>) => string;
disabled?: boolean;
id: StudyMode;
name: string;
description: string;
icon: string;
getColor: (colors: ReturnType<typeof useThemeColors>) => string;
disabled?: boolean;
}
const getStudyModes = (colors: ReturnType<typeof useThemeColors>): StudyModeOption[] => [
{
id: 'all' as StudyMode,
name: 'Alle Karten',
description: 'Alle Karten der Reihe nach durchgehen',
icon: 'albums-outline',
getColor: (c) => c.primary,
},
{
id: 'random' as StudyMode,
name: 'Zufällig',
description: 'Karten in zufälliger Reihenfolge',
icon: 'shuffle-outline',
getColor: (c) => c.secondary,
},
{
id: 'favorites' as StudyMode,
name: 'Favoriten',
description: 'Nur als Favorit markierte Karten',
icon: 'heart-outline',
getColor: (c) => c.destructive,
},
{
id: 'new' as StudyMode,
name: 'Neue Karten',
description: 'Noch nicht gelernte Karten',
icon: 'sparkles-outline',
getColor: (c) => c.accent,
disabled: false,
},
{
id: 'review' as StudyMode,
name: 'Wiederholung',
description: 'Fällige Karten wiederholen',
icon: 'refresh-outline',
getColor: (c) => c.secondary,
disabled: false,
},
{
id: 'all' as StudyMode,
name: 'Alle Karten',
description: 'Alle Karten der Reihe nach durchgehen',
icon: 'albums-outline',
getColor: (c) => c.primary,
},
{
id: 'random' as StudyMode,
name: 'Zufällig',
description: 'Karten in zufälliger Reihenfolge',
icon: 'shuffle-outline',
getColor: (c) => c.secondary,
},
{
id: 'favorites' as StudyMode,
name: 'Favoriten',
description: 'Nur als Favorit markierte Karten',
icon: 'heart-outline',
getColor: (c) => c.destructive,
},
{
id: 'new' as StudyMode,
name: 'Neue Karten',
description: 'Noch nicht gelernte Karten',
icon: 'sparkles-outline',
getColor: (c) => c.accent,
disabled: false,
},
{
id: 'review' as StudyMode,
name: 'Wiederholung',
description: 'Fällige Karten wiederholen',
icon: 'refresh-outline',
getColor: (c) => c.secondary,
disabled: false,
},
];
export const StudyModeSelector: React.FC<StudyModeSelectorProps> = ({
visible,
onClose,
onSelectMode,
cardStats,
visible,
onClose,
onSelectMode,
cardStats,
}) => {
const colors = useThemeColors();
const studyModes = getStudyModes(colors);
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}>
<View style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24, backgroundColor: colors.surface, paddingBottom: 32, paddingTop: 24 }}>
<View style={{ marginBottom: 24, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 24 }}>
<Text style={{ fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>Lernmodus wählen</Text>
<Pressable onPress={onClose} style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="close" library="Ionicons" size={24} color={colors.foreground} />
</Pressable>
</View>
const colors = useThemeColors();
const studyModes = getStudyModes(colors);
<View style={{ paddingHorizontal: 24 }}>
{studyModes.map((mode) => (
<Pressable
key={mode.id}
onPress={() => {
if (!mode.disabled) {
onSelectMode(mode.id);
onClose();
}
}}
disabled={mode.disabled}
style={({ pressed }) => ({ opacity: pressed && !mode.disabled ? 0.7 : 1 })}>
<Card
variant={mode.disabled ? 'outlined' : 'elevated'}
padding="md"
style={{ marginBottom: 12, opacity: mode.disabled ? 0.5 : 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 48,
width: 48,
backgroundColor: mode.disabled ? colors.muted : mode.getColor(colors),
marginRight: 16,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
}}>
<Icon name={mode.icon} library="Ionicons" size={24} color={mode.disabled ? colors.mutedForeground : "white"} />
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontWeight: '600', color: colors.foreground }}>{mode.name}</Text>
{mode.disabled && (
<View style={{ marginLeft: 8, borderRadius: 12, backgroundColor: colors.muted, paddingHorizontal: 8, paddingVertical: 2 }}>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>Bald verfügbar</Text>
</View>
)}
</View>
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>{mode.description}</Text>
{cardStats && mode.id === 'all' && (
<Text style={{ marginTop: 4, fontSize: 12, color: colors.mutedForeground }}>
{cardStats.total} Karten verfügbar
</Text>
)}
{cardStats && mode.id === 'favorites' && (
<Text style={{ marginTop: 4, fontSize: 12, color: colors.mutedForeground }}>
{cardStats.favorites} Favoriten verfügbar
</Text>
)}
</View>
{!mode.disabled && (
<Icon name="chevron-forward" library="Ionicons" size={20} color={colors.mutedForeground} />
)}
</View>
</Card>
</Pressable>
))}
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}>
<View
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
backgroundColor: colors.surface,
paddingBottom: 32,
paddingTop: 24,
}}
>
<View
style={{
marginBottom: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 24,
}}
>
<Text style={{ fontSize: 20, fontWeight: 'bold', color: colors.foreground }}>
Lernmodus wählen
</Text>
<Pressable onPress={onClose} style={({ pressed }) => pressed && { opacity: 0.7 }}>
<Icon name="close" library="Ionicons" size={24} color={colors.foreground} />
</Pressable>
</View>
<View style={{ marginTop: 16 }}>
<Button onPress={onClose} variant="outline" fullWidth>
Abbrechen
</Button>
</View>
</View>
</View>
</View>
</Modal>
);
<View style={{ paddingHorizontal: 24 }}>
{studyModes.map((mode) => (
<Pressable
key={mode.id}
onPress={() => {
if (!mode.disabled) {
onSelectMode(mode.id);
onClose();
}
}}
disabled={mode.disabled}
style={({ pressed }) => ({ opacity: pressed && !mode.disabled ? 0.7 : 1 })}
>
<Card
variant={mode.disabled ? 'outlined' : 'elevated'}
padding="md"
style={{ marginBottom: 12, opacity: mode.disabled ? 0.5 : 1 }}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View
style={{
height: 48,
width: 48,
backgroundColor: mode.disabled ? colors.muted : mode.getColor(colors),
marginRight: 16,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
}}
>
<Icon
name={mode.icon}
library="Ionicons"
size={24}
color={mode.disabled ? colors.mutedForeground : 'white'}
/>
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontWeight: '600', color: colors.foreground }}>
{mode.name}
</Text>
{mode.disabled && (
<View
style={{
marginLeft: 8,
borderRadius: 12,
backgroundColor: colors.muted,
paddingHorizontal: 8,
paddingVertical: 2,
}}
>
<Text style={{ fontSize: 12, color: colors.mutedForeground }}>
Bald verfügbar
</Text>
</View>
)}
</View>
<Text style={{ marginTop: 4, fontSize: 14, color: colors.mutedForeground }}>
{mode.description}
</Text>
{cardStats && mode.id === 'all' && (
<Text style={{ marginTop: 4, fontSize: 12, color: colors.mutedForeground }}>
{cardStats.total} Karten verfügbar
</Text>
)}
{cardStats && mode.id === 'favorites' && (
<Text style={{ marginTop: 4, fontSize: 12, color: colors.mutedForeground }}>
{cardStats.favorites} Favoriten verfügbar
</Text>
)}
</View>
{!mode.disabled && (
<Icon
name="chevron-forward"
library="Ionicons"
size={20}
color={colors.mutedForeground}
/>
)}
</View>
</Card>
</Pressable>
))}
<View style={{ marginTop: 16 }}>
<Button onPress={onClose} variant="outline" fullWidth>
Abbrechen
</Button>
</View>
</View>
</View>
</View>
</Modal>
);
};

View file

@ -1,223 +1,235 @@
import React from 'react';
import { Pressable, ActivityIndicator, View, PressableProps, Platform, Animated, ViewStyle, TextStyle } from 'react-native';
import {
Pressable,
ActivityIndicator,
View,
PressableProps,
Platform,
Animated,
ViewStyle,
TextStyle,
} from 'react-native';
import { Text } from './Text';
import { cva, type VariantProps } from 'class-variance-authority';
import { useThemeColors } from '~/utils/themeUtils';
const buttonVariants = cva('flex-row items-center justify-center rounded-lg transition-all', {
variants: {
variant: {
primary: '',
secondary: '',
outline: 'border-2',
ghost: '',
danger: '',
},
size: {
sm: 'px-3 py-4',
md: 'px-4 py-5',
lg: 'px-6 py-6',
xl: 'px-8 py-7',
},
fullWidth: {
true: 'w-full',
false: '',
},
disabled: {
true: 'opacity-50',
false: '',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
fullWidth: false,
disabled: false,
},
variants: {
variant: {
primary: '',
secondary: '',
outline: 'border-2',
ghost: '',
danger: '',
},
size: {
sm: 'px-3 py-4',
md: 'px-4 py-5',
lg: 'px-6 py-6',
xl: 'px-8 py-7',
},
fullWidth: {
true: 'w-full',
false: '',
},
disabled: {
true: 'opacity-50',
false: '',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
fullWidth: false,
disabled: false,
},
});
const textVariants = cva('font-semibold text-center', {
variants: {
variant: {
primary: '',
secondary: '',
outline: '',
ghost: '',
danger: '',
},
size: {
sm: 'text-base',
md: 'text-lg',
lg: 'text-xl',
xl: 'text-2xl',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
variants: {
variant: {
primary: '',
secondary: '',
outline: '',
ghost: '',
danger: '',
},
size: {
sm: 'text-base',
md: 'text-lg',
lg: 'text-xl',
xl: 'text-2xl',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
});
export interface ButtonProps
extends VariantProps<typeof buttonVariants>,
Omit<PressableProps, 'style' | 'children'> {
children: React.ReactNode;
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
className?: string;
extends VariantProps<typeof buttonVariants>,
Omit<PressableProps, 'style' | 'children'> {
children: React.ReactNode;
loading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
className?: string;
}
export const Button = React.forwardRef<View, ButtonProps>(
(
{
className,
variant,
size,
fullWidth,
disabled,
loading,
leftIcon,
rightIcon,
children,
onPress,
...props
},
ref
) => {
const isDisabled = disabled || loading;
const scaleAnim = React.useRef(new Animated.Value(1)).current;
const colors = useThemeColors();
(
{
className,
variant,
size,
fullWidth,
disabled,
loading,
leftIcon,
rightIcon,
children,
onPress,
...props
},
ref
) => {
const isDisabled = disabled || loading;
const scaleAnim = React.useRef(new Animated.Value(1)).current;
const colors = useThemeColors();
const handlePressIn = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 0.95,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
const handlePressIn = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 0.95,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
const handlePressOut = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
const handlePressOut = () => {
if (Platform.OS === 'ios') {
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
speed: 50,
bounciness: 4,
}).start();
}
};
// Get variant-specific colors
const getVariantColors = (): { backgroundColor: string; borderColor: string; textColor: string } => {
switch (variant) {
case 'primary':
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
case 'secondary':
return {
backgroundColor: colors.secondary,
borderColor: colors.secondary,
textColor: colors.secondaryForeground,
};
case 'outline':
return {
backgroundColor: 'transparent',
borderColor: colors.border,
textColor: colors.foreground,
};
case 'ghost':
return {
backgroundColor: 'transparent',
borderColor: 'transparent',
textColor: colors.foreground,
};
case 'danger':
return {
backgroundColor: colors.destructive,
borderColor: colors.destructive,
textColor: colors.destructiveForeground,
};
default:
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
}
};
// Get variant-specific colors
const getVariantColors = (): {
backgroundColor: string;
borderColor: string;
textColor: string;
} => {
switch (variant) {
case 'primary':
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
case 'secondary':
return {
backgroundColor: colors.secondary,
borderColor: colors.secondary,
textColor: colors.secondaryForeground,
};
case 'outline':
return {
backgroundColor: 'transparent',
borderColor: colors.border,
textColor: colors.foreground,
};
case 'ghost':
return {
backgroundColor: 'transparent',
borderColor: 'transparent',
textColor: colors.foreground,
};
case 'danger':
return {
backgroundColor: colors.destructive,
borderColor: colors.destructive,
textColor: colors.destructiveForeground,
};
default:
return {
backgroundColor: colors.primary,
borderColor: colors.primary,
textColor: colors.primaryForeground,
};
}
};
const variantColors = getVariantColors();
const variantColors = getVariantColors();
// Android ripple color based on variant
const getRippleColor = () => {
switch (variant) {
case 'primary':
case 'danger':
return 'rgba(255, 255, 255, 0.3)';
case 'secondary':
case 'outline':
case 'ghost':
default:
return 'rgba(0, 0, 0, 0.1)';
}
};
// Android ripple color based on variant
const getRippleColor = () => {
switch (variant) {
case 'primary':
case 'danger':
return 'rgba(255, 255, 255, 0.3)';
case 'secondary':
case 'outline':
case 'ghost':
default:
return 'rgba(0, 0, 0, 0.1)';
}
};
return (
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
<Pressable
ref={ref}
className={buttonVariants({
variant,
size,
fullWidth,
disabled: isDisabled,
className,
})}
disabled={isDisabled}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
android_ripple={{
color: getRippleColor(),
borderless: false,
}}
style={({ pressed }) => ({
backgroundColor: variantColors.backgroundColor,
borderColor: variantColors.borderColor,
borderWidth: variant === 'outline' ? 2 : 1,
...(Platform.OS === 'ios' ? { opacity: pressed ? 0.85 : 1 } : {}),
})}
{...props}>
{loading ? (
<ActivityIndicator
size="small"
color={variantColors.textColor}
/>
) : (
<>
{leftIcon && <View className="mr-2">{leftIcon}</View>}
<Text
className={textVariants({
variant,
size,
})}
style={{ color: variantColors.textColor }}>
{children}
</Text>
{rightIcon && <View className="ml-2">{rightIcon}</View>}
</>
)}
</Pressable>
</Animated.View>
);
}
return (
<Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
<Pressable
ref={ref}
className={buttonVariants({
variant,
size,
fullWidth,
disabled: isDisabled,
className,
})}
disabled={isDisabled}
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
android_ripple={{
color: getRippleColor(),
borderless: false,
}}
style={({ pressed }) => ({
backgroundColor: variantColors.backgroundColor,
borderColor: variantColors.borderColor,
borderWidth: variant === 'outline' ? 2 : 1,
...(Platform.OS === 'ios' ? { opacity: pressed ? 0.85 : 1 } : {}),
})}
{...props}
>
{loading ? (
<ActivityIndicator size="small" color={variantColors.textColor} />
) : (
<>
{leftIcon && <View className="mr-2">{leftIcon}</View>}
<Text
className={textVariants({
variant,
size,
})}
style={{ color: variantColors.textColor }}
>
{children}
</Text>
{rightIcon && <View className="ml-2">{rightIcon}</View>}
</>
)}
</Pressable>
</Animated.View>
);
}
);
Button.displayName = 'Button';

View file

@ -3,92 +3,93 @@ import { View, Pressable, ViewProps, ViewStyle } from 'react-native';
import { useThemeColors } from '~/utils/themeUtils';
interface CardProps extends ViewProps {
children: React.ReactNode;
onPress?: () => void;
variant?: 'default' | 'outlined' | 'elevated';
padding?: 'none' | 'sm' | 'md' | 'lg';
className?: string;
style?: ViewStyle;
children: React.ReactNode;
onPress?: () => void;
variant?: 'default' | 'outlined' | 'elevated';
padding?: 'none' | 'sm' | 'md' | 'lg';
className?: string;
style?: ViewStyle;
}
export const Card: React.FC<CardProps> = ({
children,
onPress,
variant = 'default',
padding = 'md',
className = '',
style,
...props
children,
onPress,
variant = 'default',
padding = 'md',
className = '',
style,
...props
}) => {
const colors = useThemeColors();
const paddingValues = {
none: 0,
sm: 8,
md: 16,
lg: 24,
};
const colors = useThemeColors();
const getVariantStyles = (): ViewStyle => {
switch (variant) {
case 'outlined':
return {
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
};
case 'elevated':
return {
backgroundColor: colors.surface,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
borderWidth: 1.5,
borderColor: colors.border,
};
case 'default':
default:
return {
backgroundColor: colors.surfaceElevated,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.10,
shadowRadius: 6,
elevation: 4,
borderWidth: 1,
borderColor: colors.border,
};
}
};
const paddingValues = {
none: 0,
sm: 8,
md: 16,
lg: 24,
};
const containerStyle: ViewStyle = {
borderRadius: 16,
overflow: 'hidden',
padding: paddingValues[padding],
...getVariantStyles(),
...style,
};
const getVariantStyles = (): ViewStyle => {
switch (variant) {
case 'outlined':
return {
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
};
case 'elevated':
return {
backgroundColor: colors.surface,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 6,
borderWidth: 1.5,
borderColor: colors.border,
};
case 'default':
default:
return {
backgroundColor: colors.surfaceElevated,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 4,
borderWidth: 1,
borderColor: colors.border,
};
}
};
if (onPress) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
...containerStyle,
opacity: pressed ? 0.95 : 1,
transform: pressed ? [{ scale: 0.98 }] : [{ scale: 1 }],
})}
{...props}>
{children}
</Pressable>
);
}
const containerStyle: ViewStyle = {
borderRadius: 16,
overflow: 'hidden',
padding: paddingValues[padding],
...getVariantStyles(),
...style,
};
return (
<View style={containerStyle} {...props}>
{children}
</View>
);
if (onPress) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
...containerStyle,
opacity: pressed ? 0.95 : 1,
transform: pressed ? [{ scale: 0.98 }] : [{ scale: 1 }],
})}
{...props}
>
{children}
</Pressable>
);
}
return (
<View style={containerStyle} {...props}>
{children}
</View>
);
};

View file

@ -5,98 +5,104 @@ import { Icon } from './Icon';
import { useThemeColors } from '~/utils/themeUtils';
export type FilterOption = {
id: string;
label: string;
icon?: string;
iconLibrary?: 'Ionicons' | 'FontAwesome' | 'MaterialIcons' | 'Feather';
count?: number;
id: string;
label: string;
icon?: string;
iconLibrary?: 'Ionicons' | 'FontAwesome' | 'MaterialIcons' | 'Feather';
count?: number;
};
interface FilterBarProps {
options: FilterOption[];
activeFilter: string;
onFilterChange: (filterId: string) => void;
options: FilterOption[];
activeFilter: string;
onFilterChange: (filterId: string) => void;
}
export const FilterBar: React.FC<FilterBarProps> = ({ options, activeFilter, onFilterChange }) => {
const colors = useThemeColors();
const colors = useThemeColors();
return (
<View
style={{
backgroundColor: colors.background,
borderTopWidth: 1,
borderTopColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.05,
shadowRadius: 3,
elevation: 5,
}}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,
paddingVertical: 12,
}}>
{options.map((option) => {
const isActive = activeFilter === option.id;
return (
<View
style={{
backgroundColor: colors.background,
borderTopWidth: 1,
borderTopColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.05,
shadowRadius: 3,
elevation: 5,
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,
paddingVertical: 12,
}}
>
{options.map((option) => {
const isActive = activeFilter === option.id;
return (
<Pressable
key={option.id}
onPress={() => onFilterChange(option.id)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: isActive ? colors.primary : colors.muted,
opacity: pressed ? 0.7 : 1,
marginRight: 8,
})}>
{option.icon && (
<Icon
name={option.icon}
size={16}
library={option.iconLibrary || 'Ionicons'}
color={isActive ? 'white' : colors.mutedForeground}
style={{ marginRight: 6 }}
/>
)}
<Text
style={{
color: isActive ? 'white' : colors.mutedForeground,
fontSize: 14,
fontWeight: '600',
}}>
{option.label}
</Text>
{option.count !== undefined && (
<View
style={{
backgroundColor: isActive ? 'rgba(255,255,255,0.2)' : colors.background,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 10,
marginLeft: 6,
}}>
<Text
style={{
color: isActive ? 'white' : colors.mutedForeground,
fontSize: 12,
fontWeight: '600',
}}>
{option.count}
</Text>
</View>
)}
</Pressable>
);
})}
</ScrollView>
</View>
);
return (
<Pressable
key={option.id}
onPress={() => onFilterChange(option.id)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
backgroundColor: isActive ? colors.primary : colors.muted,
opacity: pressed ? 0.7 : 1,
marginRight: 8,
})}
>
{option.icon && (
<Icon
name={option.icon}
size={16}
library={option.iconLibrary || 'Ionicons'}
color={isActive ? 'white' : colors.mutedForeground}
style={{ marginRight: 6 }}
/>
)}
<Text
style={{
color: isActive ? 'white' : colors.mutedForeground,
fontSize: 14,
fontWeight: '600',
}}
>
{option.label}
</Text>
{option.count !== undefined && (
<View
style={{
backgroundColor: isActive ? 'rgba(255,255,255,0.2)' : colors.background,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 10,
marginLeft: 6,
}}
>
<Text
style={{
color: isActive ? 'white' : colors.mutedForeground,
fontSize: 12,
fontWeight: '600',
}}
>
{option.count}
</Text>
</View>
)}
</Pressable>
);
})}
</ScrollView>
</View>
);
};

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