mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 16:06:43 +02:00
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:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -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 .
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
© {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">
|
||||
© {currentYear} ManaDeck. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">Made with 💜 in Germany</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_
|
||||
|
|
|
|||
|
|
@ -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_
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Redirect } from 'expo-router';
|
||||
|
||||
export default function TabsIndex() {
|
||||
return <Redirect href="/(tabs)/decks" />;
|
||||
return <Redirect href="/(tabs)/decks" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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]`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Redirect } from 'expo-router';
|
||||
|
||||
export default function Index() {
|
||||
return <Redirect href="/(tabs)" />;
|
||||
return <Redirect href="/(tabs)" />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)' }}>< 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)' }}>
|
||||
< 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue