chore: remove presi mobile app, update outdated mobile docs
- Delete apps/presi/apps/mobile/ (outdated Firebase-based, web app is primary) - Update presi CLAUDE.md: remove mobile references, fix landing page status - Update manadeck mobile CLAUDE.md: replace Supabase refs with Mana Core Auth - Fix picture mobile: pin nativewind version, add shared-logger dependency - Remove dev:presi:mobile script and env generation config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
## Project Overview
|
||||
|
||||
Manadeck is a React Native/Expo application using Expo Router for navigation, TypeScript for type safety, NativeWind (Tailwind CSS) for styling, and Supabase for backend services. The app uses Zustand for state management and is configured for cross-platform development (iOS, Android, and Web).
|
||||
Manadeck is a React Native/Expo application using Expo Router for navigation, TypeScript for type safety, NativeWind (Tailwind CSS) for styling, and Mana Core Auth for authentication. The app uses Zustand for state management and connects to the NestJS backend (port 3009).
|
||||
|
||||
## Essential Commands
|
||||
|
||||
|
|
@ -43,7 +43,8 @@ npm run format # Auto-fix ESLint and format with Prettier
|
|||
- `modal.tsx`: Modal screens
|
||||
- **components/**: Reusable UI components
|
||||
- **store/**: Zustand state management stores
|
||||
- **utils/**: Utility functions (e.g., Supabase client)
|
||||
- **services/**: Auth service, API client
|
||||
- **utils/**: Utility functions
|
||||
- **assets/**: Static assets (images, icons)
|
||||
|
||||
### Navigation Pattern
|
||||
|
|
@ -66,11 +67,17 @@ Uses Expo Router v5 with file-based routing:
|
|||
- Create typed stores with actions and selectors
|
||||
- Access via hooks (e.g., `useStore`)
|
||||
|
||||
### Authentication
|
||||
|
||||
- Mana Core Auth via `@manacore/shared-auth` in `services/authService.ts`
|
||||
- Zustand auth store in `store/authStore.ts`
|
||||
- Tokens stored in Expo SecureStore (encrypted)
|
||||
- Supports email/password, Google, and Apple sign-in
|
||||
|
||||
### 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`
|
||||
- NestJS backend at port 3009 (`@mana-core/nestjs-integration`)
|
||||
- Environment variable: `EXPO_PUBLIC_MANA_CORE_AUTH_URL`
|
||||
|
||||
### TypeScript Configuration
|
||||
|
||||
|
|
@ -98,15 +105,6 @@ For new screens:
|
|||
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
|
||||
|
|
@ -121,8 +119,8 @@ When working with Supabase:
|
|||
Create a `.env` or `.env.local` file:
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3009
|
||||
```
|
||||
|
||||
### EAS Configuration
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"@callstack/liquid-glass": "^0.4.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-logger": "workspace:*",
|
||||
"@picture/design-tokens": "workspace:*",
|
||||
"@picture/shared": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
|
@ -43,7 +44,7 @@
|
|||
"expo-symbols": "^1.0.7",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"nativewind": "latest",
|
||||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
apps/presi/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@presi/backend)
|
||||
│ ├── mobile/ # Expo/React Native mobile app (@presi/mobile)
|
||||
│ ├── web/ # SvelteKit web application (@presi/web)
|
||||
│ └── landing/ # Astro marketing landing page (@presi/landing) - TODO
|
||||
│ └── landing/ # Astro marketing landing page (@presi/landing)
|
||||
├── packages/
|
||||
│ └── shared/ # Shared types and utils (@presi/shared)
|
||||
└── package.json
|
||||
|
|
@ -20,7 +19,6 @@ apps/presi/
|
|||
|
||||
```bash
|
||||
pnpm presi:dev # Run all presi apps
|
||||
pnpm dev:presi:mobile # Start mobile app
|
||||
pnpm dev:presi:web # Start web app (port 5178)
|
||||
pnpm dev:presi:backend # Start backend server
|
||||
pnpm dev:presi:app # Start web + backend together
|
||||
|
|
@ -29,14 +27,6 @@ pnpm presi:db:studio # Open Drizzle Studio
|
|||
pnpm presi:db:seed # Seed database with sample data
|
||||
```
|
||||
|
||||
### Mobile App (apps/presi/apps/mobile)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start Expo dev server
|
||||
pnpm ios # Run on iOS simulator
|
||||
pnpm android # Run on Android emulator
|
||||
```
|
||||
|
||||
### Web App (apps/presi/apps/web)
|
||||
|
||||
```bash
|
||||
|
|
@ -59,7 +49,6 @@ pnpm db:seed # Seed database
|
|||
|
||||
## Technology Stack
|
||||
|
||||
- **Mobile**: React Native 0.76 + Expo SDK 52, Expo Router, Zustand
|
||||
- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS
|
||||
- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL
|
||||
- **Types**: TypeScript 5.x
|
||||
|
|
@ -143,13 +132,6 @@ MANA_CORE_AUTH_URL=http://localhost:3001
|
|||
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
|
||||
```
|
||||
|
||||
#### Mobile (.env)
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3008
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
#### Web (.env)
|
||||
|
||||
```
|
||||
|
|
@ -178,15 +160,14 @@ Located at `packages/shared/`
|
|||
## Code Style Guidelines
|
||||
|
||||
- **TypeScript**: Strict typing with interfaces
|
||||
- **Mobile**: Functional components with hooks, Zustand for state
|
||||
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
|
||||
- **Backend**: NestJS modules with controllers and services
|
||||
- **Styling**: Tailwind CSS (Web), NativeWind (Mobile)
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Formatting**: Prettier with project config
|
||||
|
||||
## Web App Features
|
||||
|
||||
The SvelteKit web app provides feature parity with the mobile app:
|
||||
The SvelteKit web app provides the main user interface:
|
||||
|
||||
- **Authentication**: Login/Register/Forgot Password with Mana Core Auth
|
||||
- **Deck Management**: Create, edit, delete presentation decks
|
||||
|
|
@ -229,4 +210,4 @@ src/
|
|||
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
|
||||
2. **Database**: PostgreSQL with Drizzle ORM
|
||||
3. **Ports**: Backend=3008, Web=5178
|
||||
4. **Landing**: Not yet implemented (empty folder)
|
||||
4. **Landing**: Deployed on Cloudflare Pages
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "presi",
|
||||
"slug": "presi",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "myapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "83d42377-9b68-4b82-a049-565584d893ba"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
title: 'Login',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="register"
|
||||
options={{
|
||||
title: 'Register',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="forgot-password"
|
||||
options={{
|
||||
title: 'Reset Password',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Link, useRouter } from 'expo-router';
|
||||
import { resetPassword } from '../../services/auth';
|
||||
|
||||
export default function ForgotPasswordScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!email) {
|
||||
Alert.alert('Error', 'Please enter your email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await resetPassword(email);
|
||||
setResetSent(true);
|
||||
} catch (error: any) {
|
||||
console.error('Password reset error:', error);
|
||||
Alert.alert('Reset Failed', error.message || 'An error occurred while sending reset email');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (resetSent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Check Your Email</Text>
|
||||
<Text style={styles.message}>We've sent password reset instructions to {email}</Text>
|
||||
<TouchableOpacity style={styles.button} onPress={() => router.replace('/login')}>
|
||||
<Text style={styles.buttonText}>Return to Login</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Reset Password</Text>
|
||||
<Text style={styles.subtitle}>Enter your email to receive reset instructions</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isLoading ? 'Sending...' : 'Send Reset Instructions'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.links}>
|
||||
<Link href="/login" style={styles.link}>
|
||||
Back to Login
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#dddddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#cccccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
links: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
link: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useRouter, Link } from 'expo-router';
|
||||
import { loginUser } from '../../services/auth';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await loginUser(email, password);
|
||||
router.replace('/');
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
Alert.alert('Login Failed', error.message || 'Please check your credentials and try again');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Welcome Back</Text>
|
||||
<Text style={styles.subtitle}>Sign in to continue</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>{isLoading ? 'Signing in...' : 'Sign In'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.links}>
|
||||
<Link href="/forgot-password" style={styles.link}>
|
||||
Forgot Password?
|
||||
</Link>
|
||||
<Link href="/register" style={styles.link}>
|
||||
Create Account
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#dddddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#cccccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
links: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 24,
|
||||
},
|
||||
link: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useRouter, Link } from 'expo-router';
|
||||
import { registerUser } from '../../services/auth';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!email || !password || !confirmPassword) {
|
||||
Alert.alert('Error', 'Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert('Error', 'Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
Alert.alert('Error', 'Password should be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await registerUser(email, password);
|
||||
router.replace('/');
|
||||
} catch (error: any) {
|
||||
console.error('Registration error:', error);
|
||||
Alert.alert('Registration Failed', error.message || 'An error occurred during registration');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={styles.title}>Create Account</Text>
|
||||
<Text style={styles.subtitle}>Sign up to get started</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password-new"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Confirm Password"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
autoComplete="password-new"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{isLoading ? 'Creating Account...' : 'Create Account'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.links}>
|
||||
<Link href="/login" style={styles.link}>
|
||||
Already have an account? Sign In
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
formContainer: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#1a1a1a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666666',
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#dddddd',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#007AFF',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: '#cccccc',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
links: {
|
||||
marginTop: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
link: {
|
||||
color: '#007AFF',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useSegments, useRouter } from 'expo-router';
|
||||
import { View, TouchableOpacity, Text } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { onAuthStateChange } from '../services/auth';
|
||||
import { ThemeProvider, useTheme } from '../components/ThemeProvider';
|
||||
import { Header } from '../components/Menu/Header';
|
||||
|
||||
function StackNavigator() {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
header: ({ route, options }) => {
|
||||
let title = options.title || '';
|
||||
let showAddDeck = false;
|
||||
const rightContent = options.headerRight?.({});
|
||||
|
||||
if (route.name === 'index') {
|
||||
title = `My Decks (${route.params?.deckCount || 0})`;
|
||||
showAddDeck = true;
|
||||
}
|
||||
|
||||
return <Header title={title} showAddDeck={showAddDeck} rightContent={rightContent} />;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'My Decks',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="decks/[id]"
|
||||
options={{
|
||||
title: 'Deck Details',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="deck/[id]"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="create-deck"
|
||||
options={{
|
||||
title: 'Create New Deck',
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/login"
|
||||
options={{
|
||||
title: 'Login',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/register"
|
||||
options={{
|
||||
title: 'Register',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutContent() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<StackNavigator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChange((user) => {
|
||||
if (!user && !segments.includes('(auth)')) {
|
||||
router.replace('/login');
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [segments]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<RootLayoutContent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
Modal,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { getDeckSlides, getDeck, deleteSlide, reorderSlide } from '../../services/firestore';
|
||||
import { Slide } from '../../types/models';
|
||||
import type { Deck } from '../../types/models';
|
||||
import { SlideList } from '../../components/slides/SlideList';
|
||||
import { SlideEditor } from '../../components/slides/SlideEditor';
|
||||
import { PresentationMode } from '../../components/presentation/PresentationMode';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useTheme } from '../../components/ThemeProvider';
|
||||
import { Header } from '../../components/Menu/Header';
|
||||
|
||||
export default function DeckScreen() {
|
||||
const { theme } = useTheme();
|
||||
const { id } = useLocalSearchParams();
|
||||
const router = useRouter();
|
||||
const [slides, setSlides] = useState<Slide[]>([]);
|
||||
const [deck, setDeck] = useState<Deck | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
const [editingSlide, setEditingSlide] = useState<Slide | null>(null);
|
||||
const [isPresentationMode, setIsPresentationMode] = useState(false);
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
|
||||
const loadDeckAndSlides = async () => {
|
||||
try {
|
||||
const [deckData, deckSlides] = await Promise.all([
|
||||
getDeck(id as string),
|
||||
getDeckSlides(id as string),
|
||||
]);
|
||||
console.log(
|
||||
'[DeckScreen] Loaded slides:',
|
||||
deckSlides.map((s) => ({
|
||||
id: s.id.substring(0, 4),
|
||||
order: s.order,
|
||||
updatedAt: s.updatedAt,
|
||||
}))
|
||||
);
|
||||
setDeck(deckData);
|
||||
const sortedSlides = deckSlides.sort((a, b) => a.order - b.order);
|
||||
console.log(
|
||||
'[DeckScreen] Sorted slides:',
|
||||
sortedSlides.map((s) => ({
|
||||
id: s.id.substring(0, 4),
|
||||
order: s.order,
|
||||
updatedAt: s.updatedAt,
|
||||
}))
|
||||
);
|
||||
setSlides(sortedSlides);
|
||||
} catch (error) {
|
||||
console.error('Error loading deck data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartPresentation = useCallback(() => {
|
||||
if (slides.length > 0) {
|
||||
setIsPresentationMode(true);
|
||||
}
|
||||
}, [slides.length]);
|
||||
|
||||
const handleDeckDelete = useCallback(() => {
|
||||
setIsDeleteModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleEditSlide = useCallback((slide: Slide) => {
|
||||
setEditingSlide(slide);
|
||||
setIsCreateModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteDeck = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// await deleteDeck(id as string, setId as string);
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('[DeckScreen] Error deleting deck:', error);
|
||||
// setError('Failed to delete deck');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsDeleteModalVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSlide = useCallback(
|
||||
async (slide: Slide) => {
|
||||
try {
|
||||
await deleteSlide(slide.id, id as string);
|
||||
await loadDeckAndSlides();
|
||||
} catch (error) {
|
||||
console.error('[DeckScreen] Error deleting slide:', error);
|
||||
Alert.alert('Error', 'Failed to delete slide');
|
||||
}
|
||||
},
|
||||
[loadDeckAndSlides, id]
|
||||
);
|
||||
|
||||
const handleMoveSlide = useCallback(
|
||||
async (slide: Slide, direction: 'up' | 'down') => {
|
||||
console.log('[DeckScreen] Starting handleMoveSlide:', {
|
||||
slideId: slide.id.substring(0, 4),
|
||||
direction,
|
||||
currentOrder: slide.order,
|
||||
});
|
||||
const currentIndex = slides.findIndex((s) => s.id === slide.id);
|
||||
console.log(
|
||||
'[DeckScreen] Current slide index:',
|
||||
currentIndex,
|
||||
'Total slides:',
|
||||
slides.length
|
||||
);
|
||||
console.log(
|
||||
'[DeckScreen] All slides:',
|
||||
slides.map((s) => ({
|
||||
id: s.id.substring(0, 4),
|
||||
order: s.order,
|
||||
}))
|
||||
);
|
||||
if (currentIndex === -1) {
|
||||
console.log('[DeckScreen] Slide not found in array');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize all orders to be integers between 1 and slides.length
|
||||
const normalizedSlides = [...slides].sort((a, b) => a.order - b.order);
|
||||
const normalizedOrders = new Map(normalizedSlides.map((s, i) => [s.id, i + 1]));
|
||||
|
||||
let newOrder;
|
||||
if (direction === 'up' && currentIndex > 0) {
|
||||
// Moving up: use the previous slide's normalized order
|
||||
const prevOrder = normalizedOrders.get(slides[currentIndex - 1].id) || 1;
|
||||
const currOrder = normalizedOrders.get(slide.id) || 2;
|
||||
newOrder = prevOrder + (currOrder - prevOrder) / 2;
|
||||
|
||||
console.log('[DeckScreen] Moving up - New order:', {
|
||||
newOrder,
|
||||
previousSlideId: slides[currentIndex - 1].id.substring(0, 4),
|
||||
previousOrder: prevOrder,
|
||||
currentSlideId: slides[currentIndex].id.substring(0, 4),
|
||||
currentOrder: currOrder,
|
||||
});
|
||||
} else if (direction === 'down' && currentIndex < slides.length - 1) {
|
||||
// Moving down: use the next slide's normalized order
|
||||
const currOrder = normalizedOrders.get(slide.id) || 1;
|
||||
const nextOrder = normalizedOrders.get(slides[currentIndex + 1].id) || 2;
|
||||
newOrder = currOrder + (nextOrder - currOrder) / 2;
|
||||
|
||||
console.log('[DeckScreen] Moving down - New order:', {
|
||||
newOrder,
|
||||
nextSlideId: slides[currentIndex + 1].id.substring(0, 4),
|
||||
nextOrder: nextOrder,
|
||||
currentSlideId: slides[currentIndex].id.substring(0, 4),
|
||||
currentOrder: currOrder,
|
||||
});
|
||||
} else {
|
||||
console.log('[DeckScreen] Cannot move slide:', {
|
||||
direction,
|
||||
currentIndex,
|
||||
slidesLength: slides.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[DeckScreen] Calling reorderSlide with:', {
|
||||
slideId: slide.id.substring(0, 4),
|
||||
newOrder,
|
||||
deckId: id,
|
||||
});
|
||||
await reorderSlide(slide.id, newOrder, id as string);
|
||||
console.log('[DeckScreen] Reorder successful, reloading slides');
|
||||
await loadDeckAndSlides();
|
||||
} catch (error) {
|
||||
console.error('[DeckScreen] Error moving slide:', error);
|
||||
Alert.alert('Error', 'Failed to move slide');
|
||||
}
|
||||
},
|
||||
[slides, loadDeckAndSlides, id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadDeckAndSlides();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deck) {
|
||||
router.setParams({
|
||||
deckName: deck.name,
|
||||
slideCount: slides.length,
|
||||
onStartPresentation: handleStartPresentation,
|
||||
onDeleteDeck: handleDeckDelete,
|
||||
});
|
||||
}
|
||||
}, [deck, slides, handleStartPresentation, handleDeckDelete]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18, fontWeight: '500', color: theme.colors.textPrimary }}>
|
||||
Loading slides...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: 'transparent' }}>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SlideList
|
||||
slides={slides}
|
||||
onEditSlide={handleEditSlide}
|
||||
onDeleteSlide={handleDeleteSlide}
|
||||
onMoveSlide={handleMoveSlide}
|
||||
/>
|
||||
<Header
|
||||
title={deck?.name || 'Loading...'}
|
||||
showPresent
|
||||
onPresentPress={handleStartPresentation}
|
||||
disabled={!slides.length}
|
||||
slideCount={slides.length}
|
||||
position="bottom"
|
||||
/>
|
||||
</View>
|
||||
<Modal
|
||||
visible={isCreateModalVisible}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
>
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: `${theme.colors.backgroundPrimary}CC` }}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: 16,
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
maxWidth: 800,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20, fontWeight: '600', color: theme.colors.textPrimary }}>
|
||||
{editingSlide ? 'Edit Slide' : 'Create New Slide'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
}}
|
||||
onPress={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
<View style={{ padding: 16 }}>
|
||||
<SlideEditor
|
||||
deckId={id as string}
|
||||
slide={editingSlide}
|
||||
onSuccess={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
loadDeckAndSlides();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsCreateModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={isDeleteModalVisible}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => setIsDeleteModalVisible(false)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: `${theme.colors.backgroundPrimary}CC`,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: '90%',
|
||||
maxWidth: 600,
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20, fontWeight: '600', color: theme.colors.textPrimary }}>
|
||||
Delete Slide
|
||||
</Text>
|
||||
<Text style={{ fontSize: 16, marginBottom: 20, color: theme.colors.textSecondary }}>
|
||||
Are you sure you want to delete this slide? This action cannot be undone.
|
||||
</Text>
|
||||
<View
|
||||
style={{ flexDirection: 'row', justifyContent: 'flex-end', gap: 12, marginTop: 20 }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
}}
|
||||
onPress={() => {
|
||||
setIsDeleteModalVisible(false);
|
||||
setEditingSlide(null);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ fontSize: 16, fontWeight: '500', color: theme.colors.textPrimary }}
|
||||
>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: theme.colors.error,
|
||||
}}
|
||||
onPress={handleDeleteSlide}
|
||||
>
|
||||
<Text
|
||||
style={{ fontSize: 16, fontWeight: '500', color: theme.colors.textOnPrimary }}
|
||||
>
|
||||
Delete
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={isPresentationMode}
|
||||
animationType="fade"
|
||||
transparent={false}
|
||||
onRequestClose={() => setIsPresentationMode(false)}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.backgroundPage }}>
|
||||
<PresentationMode slides={slides} onClose={() => setIsPresentationMode(false)} />
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,418 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform, Image } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import {
|
||||
getUserDecks,
|
||||
deleteDeck,
|
||||
getDeckSlides,
|
||||
migrateDecksToNewSchema,
|
||||
} from '../services/firestore';
|
||||
import { Deck, Slide } from '../types/models';
|
||||
import { DeckList } from '../components/decks/DeckList';
|
||||
import { CreateDeckForm } from '../components/forms/CreateDeckForm';
|
||||
import { DeckShareSettings } from '../components/decks/DeckShareSettings';
|
||||
import { auth } from '../firebaseConfig';
|
||||
import { onAuthStateChanged } from 'firebase/auth';
|
||||
import type { User } from 'firebase/auth';
|
||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||
import { ThemeProvider } from '../components/ThemeProvider';
|
||||
import { useTheme } from '../components/ThemeProvider';
|
||||
import { db } from '../firebaseConfig';
|
||||
import { updateDoc, doc } from 'firebase/firestore';
|
||||
import { ThemeVariant, THEME_PATTERNS, THEME_NAMES } from '../constants/theme';
|
||||
import { CreateItemButton } from '../components/common/CreateItemButton';
|
||||
|
||||
function App() {
|
||||
const router = useRouter();
|
||||
const { theme, themeVariant, colorMode } = useTheme();
|
||||
const [decks, setDecks] = useState<Deck[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
const [isShareModalVisible, setIsShareModalVisible] = useState(false);
|
||||
const [deckToDelete, setDeckToDelete] = useState<Deck | null>(null);
|
||||
const [deckToShare, setDeckToShare] = useState<Deck | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [decksWithFirstSlide, setDecksWithFirstSlide] = useState<{ [key: string]: string }>({});
|
||||
const [slideCounts, setSlideCounts] = useState<{ [key: string]: number }>({});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// Listen for the create deck modal event
|
||||
const handleOpenCreateModal = () => {
|
||||
setIsCreateModalVisible(true);
|
||||
};
|
||||
window.addEventListener('openCreateDeckModal', handleOpenCreateModal);
|
||||
|
||||
return () => {
|
||||
setMounted(false);
|
||||
window.removeEventListener('openCreateDeckModal', handleOpenCreateModal);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted && decks) {
|
||||
router.setParams({ deckCount: decks.length });
|
||||
}
|
||||
}, [decks, mounted]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[App] Setting up auth state listener');
|
||||
let mounted = true;
|
||||
|
||||
const loadDecksForUser = async (currentUser: User) => {
|
||||
if (!currentUser || !mounted) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Migrate existing decks to new schema
|
||||
await migrateDecksToNewSchema(currentUser.uid);
|
||||
|
||||
const userDecks = await getUserDecks(currentUser.uid);
|
||||
if (mounted) {
|
||||
setDecks(userDecks);
|
||||
|
||||
// Load first slide for each deck
|
||||
const firstSlides: { [key: string]: string } = {};
|
||||
const counts: { [key: string]: number } = {};
|
||||
|
||||
for (const deck of userDecks) {
|
||||
try {
|
||||
const slides = await getDeckSlides(deck.id);
|
||||
if (slides.length > 0) {
|
||||
firstSlides[deck.id] = slides[0].imageUrl || '';
|
||||
}
|
||||
counts[deck.id] = slides.length;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting slides:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setDecksWithFirstSlide(firstSlides);
|
||||
setSlideCounts(counts);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[App] Error loading decks:', error);
|
||||
if (mounted) {
|
||||
setError('Failed to load decks');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
|
||||
console.log('[App] Auth state changed:', currentUser?.email);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (currentUser) {
|
||||
setUser(currentUser);
|
||||
await loadDecksForUser(currentUser);
|
||||
} else {
|
||||
setUser(null);
|
||||
setDecks([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDeckPress = (deck: Deck) => {
|
||||
router.push(`/deck/${deck.id}`);
|
||||
};
|
||||
|
||||
const handleDeckDelete = (deck: Deck) => {
|
||||
setDeckToDelete(deck);
|
||||
setIsDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const handleShareDeck = (deck: Deck) => {
|
||||
setDeckToShare(deck);
|
||||
setIsShareModalVisible(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deckToDelete) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await deleteDeck(deckToDelete.id);
|
||||
const updatedDecks = decks.filter((deck) => deck.id !== deckToDelete.id);
|
||||
setDecks(updatedDecks);
|
||||
} catch (error) {
|
||||
console.error('[App] Error deleting deck:', error);
|
||||
setError('Failed to delete deck');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsDeleteModalVisible(false);
|
||||
setDeckToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={styles.content}>
|
||||
<DeckList
|
||||
decks={decks}
|
||||
onDeckPress={(deck) => {
|
||||
router.push(`/deck/${deck.id}`);
|
||||
}}
|
||||
onCreateDeck={() => setIsCreateModalVisible(true)}
|
||||
onDeleteDeck={(deck) => {
|
||||
setDeckToDelete(deck);
|
||||
setIsDeleteModalVisible(true);
|
||||
}}
|
||||
onShareDeck={(deck) => {
|
||||
setDeckToShare(deck);
|
||||
setIsShareModalVisible(true);
|
||||
}}
|
||||
firstSlideImages={decksWithFirstSlide}
|
||||
loading={loading}
|
||||
slideCounts={slideCounts}
|
||||
onProfilePress={() => router.push('/profile')}
|
||||
onSettingsPress={() => router.push('/settings')}
|
||||
scrollPadding={{
|
||||
vertical: { top: 20, bottom: 80 },
|
||||
horizontal: { top: 200, bottom: 200 },
|
||||
}}
|
||||
deckSpacing={{ vertical: 24, horizontal: 40 }}
|
||||
headerRight={() => (
|
||||
<View style={{ flexDirection: 'row', gap: 15, marginRight: 15 }}>
|
||||
<CreateItemButton
|
||||
onPress={() => setIsCreateModalVisible(true)}
|
||||
variant="button"
|
||||
title="Create New Deck"
|
||||
buttonText="Create New Deck"
|
||||
/>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/profile')}
|
||||
style={[styles.iconButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<MaterialIcons name="account-circle" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/settings')}
|
||||
style={[styles.iconButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<MaterialIcons name="settings" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Create Deck Modal */}
|
||||
<Modal
|
||||
visible={isCreateModalVisible}
|
||||
onRequestClose={() => setIsCreateModalVisible(false)}
|
||||
animationType="fade"
|
||||
transparent
|
||||
>
|
||||
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0, 0, 0, 0.5)' }]}>
|
||||
<View
|
||||
style={[styles.modalContainer, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<CreateDeckForm
|
||||
userId={user?.uid || ''}
|
||||
onSuccess={(newDeck) => {
|
||||
setIsCreateModalVisible(false);
|
||||
setDecks([newDeck, ...decks]);
|
||||
router.push(`/deck/${newDeck.id}`);
|
||||
}}
|
||||
onCancel={() => setIsCreateModalVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Deck Modal */}
|
||||
<Modal
|
||||
visible={isDeleteModalVisible}
|
||||
onRequestClose={() => {
|
||||
setIsDeleteModalVisible(false);
|
||||
setDeckToDelete(null);
|
||||
}}
|
||||
transparent
|
||||
animationType="fade"
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View
|
||||
style={[styles.modalContent, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<Text style={[styles.modalTitle, { color: theme.colors.textPrimary }]}>
|
||||
Delete Deck
|
||||
</Text>
|
||||
<Text style={[styles.modalText, { color: theme.colors.textSecondary }]}>
|
||||
Are you sure you want to delete "{deckToDelete?.name}"? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
<View style={styles.modalButtons}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalButton,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary },
|
||||
]}
|
||||
onPress={() => {
|
||||
setIsDeleteModalVisible(false);
|
||||
setDeckToDelete(null);
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, { backgroundColor: theme.colors.error }]}
|
||||
onPress={confirmDelete}
|
||||
>
|
||||
<Text style={[styles.buttonText, styles.deleteButtonText]}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Share Deck Modal */}
|
||||
<Modal
|
||||
visible={isShareModalVisible}
|
||||
onRequestClose={() => setIsShareModalVisible(false)}
|
||||
transparent
|
||||
animationType="fade"
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View
|
||||
style={[styles.modalContent, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<DeckShareSettings
|
||||
deck={deckToShare!}
|
||||
onUpdateSharing={async (sharing) => {
|
||||
if (!deckToShare) return;
|
||||
try {
|
||||
await updateDoc(doc(db, 'decks', deckToShare.id), { sharing });
|
||||
// Refresh decks list
|
||||
if (user) {
|
||||
const updatedDecks = await getUserDecks(user.uid);
|
||||
setDecks(updatedDecks);
|
||||
}
|
||||
setIsShareModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Error updating sharing settings:', error);
|
||||
}
|
||||
}}
|
||||
onClose={() => setIsShareModalVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
modalContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modalContent: {
|
||||
padding: 20,
|
||||
borderRadius: 8,
|
||||
width: '80%',
|
||||
maxWidth: 500,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 10,
|
||||
},
|
||||
modalText: {
|
||||
fontSize: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
modalButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
buttonText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
errorContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 5,
|
||||
elevation: 5,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '../components/ThemeProvider';
|
||||
import { getUserDecks, getDeckSlides } from '../services/firestore';
|
||||
import { auth } from '../firebaseConfig';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
interface UserStats {
|
||||
totalDecks: number;
|
||||
totalSlides: number;
|
||||
}
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { theme } = useTheme();
|
||||
const [stats, setStats] = useState<UserStats>({ totalDecks: 0, totalSlides: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStats() {
|
||||
if (!auth.currentUser) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const decks = await getUserDecks(auth.currentUser.uid);
|
||||
let totalSlides = 0;
|
||||
|
||||
// Get slides for each deck
|
||||
for (const deck of decks) {
|
||||
const slides = await getDeckSlides(deck.id);
|
||||
totalSlides += slides.length;
|
||||
}
|
||||
|
||||
setStats({
|
||||
totalDecks: decks.length,
|
||||
totalSlides: totalSlides,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading user stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<Text style={[styles.loadingText, { color: theme.colors.textPrimary }]}>
|
||||
Loading stats...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={[styles.header, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<MaterialIcons name="account-circle" size={80} color={theme.colors.textPrimary} />
|
||||
</View>
|
||||
<Text style={[styles.userName, { color: theme.colors.textPrimary }]}>
|
||||
{auth.currentUser?.email || 'User'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statsContainer, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<View style={styles.statItem}>
|
||||
<MaterialIcons name="folder" size={32} color={theme.colors.textPrimary} />
|
||||
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>
|
||||
{stats.totalDecks}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textSecondary }]}>Total Decks</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: theme.colors.borderPrimary }]} />
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<MaterialIcons name="slideshow" size={32} color={theme.colors.textPrimary} />
|
||||
<Text style={[styles.statValue, { color: theme.colors.textPrimary }]}>
|
||||
{stats.totalSlides}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, { color: theme.colors.textSecondary }]}>
|
||||
Total Slides
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatarContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
userName: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
marginTop: 8,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: '80%',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { getCurrentUser, logoutUser } from '../services/auth';
|
||||
import { useTheme } from '../components/ThemeProvider';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { ThemeVariant, getTheme, THEME_PATTERNS, THEME_NAMES } from '../constants/theme';
|
||||
import { ThemeSettings } from '../components/common/ThemeSettings';
|
||||
|
||||
const COLOR_MODES: { label: string; value: ColorMode }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Hell', value: 'light' },
|
||||
{ label: 'Dunkel', value: 'dark' },
|
||||
];
|
||||
|
||||
const CONTRAST_LABELS: Record<ContrastLevel, string> = {
|
||||
1: 'Sehr niedrig',
|
||||
2: 'Niedrig',
|
||||
3: 'Standard',
|
||||
4: 'Hoch',
|
||||
5: 'Sehr hoch',
|
||||
};
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter();
|
||||
const currentUser = getCurrentUser();
|
||||
const { theme, isDark } = useTheme();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logoutUser();
|
||||
router.replace('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={styles.content}>
|
||||
<ThemeSettings />
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Kontrast:</Text>
|
||||
<View style={styles.contrastContainer}>
|
||||
<View style={styles.contrastSlider}>
|
||||
{([1, 2, 3, 4, 5] as ContrastLevel[]).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
style={[
|
||||
styles.contrastOption,
|
||||
{
|
||||
backgroundColor:
|
||||
level === 3 ? theme.colors.primary : theme.colors.backgroundSecondary,
|
||||
},
|
||||
]}
|
||||
onPress={() => {}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.contrastLabel, { color: theme.colors.textPrimary }]}>
|
||||
{CONTRAST_LABELS[3]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomSection}>
|
||||
<View style={[styles.emailSection, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Email</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>
|
||||
{currentUser?.email}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.logoutButton, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
<Text style={[styles.logoutButtonText, { color: theme.colors.textPrimary }]}>
|
||||
Sign Out
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
sectionTitleContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
value: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contrastContainer: {
|
||||
marginTop: 16,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
contrastSlider: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
height: 48,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
contrastOption: {
|
||||
flex: 1,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
},
|
||||
contrastLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
bottomSection: {
|
||||
gap: 12,
|
||||
marginTop: 'auto',
|
||||
},
|
||||
emailSection: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
logoutButton: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoutButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '../../components/ThemeProvider';
|
||||
import { Deck } from '../../types/models';
|
||||
import { doc, getDoc } from 'firebase/firestore';
|
||||
import { db } from '../../firebaseConfig';
|
||||
import { SlideList } from '../../components/slides/SlideList';
|
||||
|
||||
export default function SharedDeckView() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const { theme } = useTheme();
|
||||
const [deck, setDeck] = useState<Deck | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDeck = async () => {
|
||||
try {
|
||||
const deckDoc = await getDoc(doc(db, 'decks', id as string));
|
||||
if (!deckDoc.exists()) {
|
||||
setError('Deck not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const deckData = deckDoc.data() as Deck;
|
||||
if (!deckData.sharing.isPublic) {
|
||||
setError('This deck is not publicly accessible');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeck(deckData);
|
||||
} catch (err) {
|
||||
setError('Failed to load deck');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDeck();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.text, { color: theme.colors.textPrimary }]}>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.text, { color: theme.colors.error }]}>{error}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deck) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{deck.name}</Text>
|
||||
{deck.description && (
|
||||
<Text style={[styles.description, { color: theme.colors.textSecondary }]}>
|
||||
{deck.description}
|
||||
</Text>
|
||||
)}
|
||||
<SlideList deckId={id as string} isReadOnly />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Image } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTheme } from '../components/ThemeProvider';
|
||||
import type { ThemeVariant } from '../components/ThemeProvider';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
const THEME_PATTERNS: Record<ThemeVariant, any> = {
|
||||
lume: require('../assets/images/patterns/memo-theme-tile.png'),
|
||||
nature: require('../assets/images/patterns/nature-theme-tile.png'),
|
||||
stone: require('../assets/images/patterns/stone-theme-tile.png'),
|
||||
};
|
||||
|
||||
const THEME_NAMES: Record<ThemeVariant, string> = {
|
||||
lume: 'Lume',
|
||||
nature: 'Nature',
|
||||
stone: 'Stone',
|
||||
};
|
||||
|
||||
export default function ThemesScreen() {
|
||||
const router = useRouter();
|
||||
const { theme, themeVariant, setThemeVariant } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
<View style={[styles.header, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<MaterialIcons name="arrow-back" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Designs</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<View style={styles.themeGrid}>
|
||||
{(Object.keys(THEME_NAMES) as ThemeVariant[]).map((variant) => {
|
||||
const isSelected = variant === themeVariant;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={variant}
|
||||
style={[
|
||||
styles.themeCard,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderColor: isSelected ? theme.colors.primary : 'transparent',
|
||||
borderWidth: isSelected ? 2 : 0,
|
||||
},
|
||||
]}
|
||||
onPress={() => setThemeVariant(variant)}
|
||||
>
|
||||
{THEME_PATTERNS[variant] && (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<View style={styles.patternContainer}>
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
source={THEME_PATTERNS[variant]}
|
||||
style={[styles.patternTile, { opacity: 0.15 }]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<Text style={[styles.themeName, { color: theme.colors.textPrimary }]}>
|
||||
{THEME_NAMES[variant]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
paddingTop: 60,
|
||||
},
|
||||
backButton: {
|
||||
marginRight: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
themeGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
},
|
||||
themeCard: {
|
||||
width: '100%',
|
||||
aspectRatio: 2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
justifyContent: 'flex-end',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
patternContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
patternTile: {
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
},
|
||||
themeName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
|
@ -1,156 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, TouchableOpacity, Text, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
showAddDeck?: boolean;
|
||||
showPresent?: boolean;
|
||||
onPresentPress?: () => void;
|
||||
disabled?: boolean;
|
||||
slideCount?: number;
|
||||
rightContent?: React.ReactNode;
|
||||
position?: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
title,
|
||||
showAddDeck = false,
|
||||
showPresent = false,
|
||||
onPresentPress,
|
||||
disabled = false,
|
||||
slideCount,
|
||||
rightContent,
|
||||
position = 'top',
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { theme } = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const isMobile = width < 768;
|
||||
const shouldBeBottom = isMobile && position === 'bottom';
|
||||
|
||||
const defaultRightContent = (
|
||||
<View style={styles.rightContent}>
|
||||
{!showPresent && (
|
||||
<View style={[styles.iconWrapper, { borderColor: theme.colors.borderPrimary }]}>
|
||||
<TouchableOpacity onPress={() => router.push('/profile')} style={styles.iconButton}>
|
||||
<MaterialIcons name="account-circle" size={24} color={theme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{showPresent && (
|
||||
<View
|
||||
style={[
|
||||
styles.iconWrapper,
|
||||
{ borderColor: disabled ? theme.colors.borderPrimary : theme.colors.primary },
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity onPress={onPresentPress} style={styles.iconButton} disabled={disabled}>
|
||||
<MaterialIcons
|
||||
name="slideshow"
|
||||
size={24}
|
||||
color={disabled ? theme.colors.textTertiary : theme.colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.iconWrapper, { borderColor: theme.colors.borderPrimary }]}>
|
||||
<TouchableOpacity onPress={() => router.push('/settings')} style={styles.iconButton}>
|
||||
<MaterialIcons name="settings" size={24} color={theme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{showAddDeck && (
|
||||
<View style={[styles.iconWrapper, { borderColor: theme.colors.primary }]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
const event = new CustomEvent('openCreateDeckModal');
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
style={styles.iconButton}
|
||||
>
|
||||
<MaterialIcons name="add" size={24} color={theme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.header,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderBottomColor: shouldBeBottom ? 'transparent' : theme.colors.borderPrimary,
|
||||
borderTopColor: shouldBeBottom ? theme.colors.borderPrimary : 'transparent',
|
||||
borderTopWidth: shouldBeBottom ? 1 : 0,
|
||||
borderBottomWidth: shouldBeBottom ? 0 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.titleContainer}>
|
||||
<View style={styles.titleContent}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
{typeof slideCount === 'number' && (
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
{slideCount} Slides
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.rightContainer}>{rightContent || defaultRightContent}</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: 56,
|
||||
gap: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
maxWidth: '50%',
|
||||
},
|
||||
titleContent: {
|
||||
padding: 8,
|
||||
width: '100%',
|
||||
},
|
||||
rightContainer: {
|
||||
flex: 1,
|
||||
maxWidth: '50%',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
},
|
||||
rightContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
},
|
||||
iconWrapper: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { getTheme } from '../constants/theme';
|
||||
import type { Theme, ThemeVariant } from '../constants/theme';
|
||||
|
||||
export type ColorMode = 'system' | 'light' | 'dark';
|
||||
export type ContrastLevel = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
type ColorType = 'text' | 'primary' | 'background';
|
||||
|
||||
// Hilfsfunktion zum Konvertieren von Hex zu RGB
|
||||
const hexToRgb = (hex: string) => {
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.substr(0, 2), 16),
|
||||
g: parseInt(h.substr(2, 2), 16),
|
||||
b: parseInt(h.substr(4, 2), 16),
|
||||
};
|
||||
};
|
||||
|
||||
// Hilfsfunktion zum Konvertieren von RGB zu Hex mit Alpha
|
||||
const rgbaToHex = (r: number, g: number, b: number, a = 1) => {
|
||||
const alpha = Math.round(a * 255);
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b, alpha]
|
||||
.map((x) => {
|
||||
const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
};
|
||||
|
||||
// Funktion zum Anpassen des Kontrasts
|
||||
const adjustContrast = (
|
||||
color: string,
|
||||
level: ContrastLevel,
|
||||
type: ColorType,
|
||||
isDark: boolean
|
||||
): string => {
|
||||
if (level === 3) return color;
|
||||
|
||||
const { r, g, b } = hexToRgb(color);
|
||||
|
||||
if (level < 3) {
|
||||
// Niedrigerer Kontrast: Nur Text-Opacity wird reduziert
|
||||
if (type === 'text') {
|
||||
const opacity = 0.5 + (level - 1) * 0.25; // 0.5 für Level 1, 0.75 für Level 2
|
||||
return rgbaToHex(r, g, b, opacity);
|
||||
}
|
||||
return color;
|
||||
} else {
|
||||
// Höherer Kontrast: Nur Hintergründe werden angepasst
|
||||
if (type === 'background') {
|
||||
const factor = (level - 3) * 0.45; // 0.45 für Level 4, 0.9 für Level 5
|
||||
if (isDark) {
|
||||
// Im Dark Mode: Hintergründe werden schwärzer
|
||||
return rgbaToHex(
|
||||
Math.round(r * (1 - factor)),
|
||||
Math.round(g * (1 - factor)),
|
||||
Math.round(b * (1 - factor))
|
||||
);
|
||||
} else {
|
||||
// Im Light Mode: Hintergründe werden weißer
|
||||
return rgbaToHex(
|
||||
Math.round(r + (255 - r) * factor),
|
||||
Math.round(g + (255 - g) * factor),
|
||||
Math.round(b + (255 - b) * factor)
|
||||
);
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
};
|
||||
|
||||
const adjustThemeContrast = (theme: Theme, level: ContrastLevel, isDark: boolean): Theme => {
|
||||
if (level === 3) return theme;
|
||||
|
||||
const adjustedColors = Object.entries(theme.colors).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (typeof value === 'string' && value.startsWith('#')) {
|
||||
let colorType: ColorType = 'background';
|
||||
if (key.toLowerCase().includes('text')) {
|
||||
colorType = 'text';
|
||||
} else if (key.toLowerCase().includes('primary')) {
|
||||
colorType = 'primary';
|
||||
}
|
||||
|
||||
acc[key] = adjustContrast(value, level, colorType, isDark);
|
||||
} else {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
return {
|
||||
...theme,
|
||||
colors: adjustedColors,
|
||||
};
|
||||
};
|
||||
|
||||
type ThemeContextType = {
|
||||
theme: Theme;
|
||||
isDark: boolean;
|
||||
colorMode: ColorMode;
|
||||
setColorMode: (mode: ColorMode) => void;
|
||||
themeVariant: ThemeVariant;
|
||||
setThemeVariant: (variant: ThemeVariant) => void;
|
||||
contrastLevel: ContrastLevel;
|
||||
setContrastLevel: (level: ContrastLevel) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: getTheme('light'),
|
||||
isDark: false,
|
||||
colorMode: 'system',
|
||||
setColorMode: () => {},
|
||||
themeVariant: 'default',
|
||||
setThemeVariant: () => {},
|
||||
contrastLevel: 3,
|
||||
setContrastLevel: () => {},
|
||||
});
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [colorMode, setColorMode] = useState<ColorMode>('system');
|
||||
const [themeVariant, setThemeVariant] = useState<ThemeVariant>('default');
|
||||
const [contrastLevel, setContrastLevel] = useState<ContrastLevel>(3);
|
||||
|
||||
const isDark = useMemo(() => {
|
||||
if (colorMode === 'system') {
|
||||
return systemColorScheme === 'dark';
|
||||
}
|
||||
return colorMode === 'dark';
|
||||
}, [colorMode, systemColorScheme]);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDark ? 'dark' : 'light', themeVariant);
|
||||
return adjustThemeContrast(baseTheme, contrastLevel, isDark);
|
||||
}, [isDark, themeVariant, contrastLevel]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
isDark,
|
||||
colorMode,
|
||||
setColorMode,
|
||||
themeVariant,
|
||||
setThemeVariant,
|
||||
contrastLevel,
|
||||
setContrastLevel,
|
||||
}),
|
||||
[theme, isDark, colorMode, themeVariant, contrastLevel]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface ButtonProps {
|
||||
onPress: () => void;
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
onPress,
|
||||
children,
|
||||
variant = 'primary',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={loading || disabled}
|
||||
style={[styles.button, styles[variant], (loading || disabled) && styles.disabled]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={[styles.text, styles[`${variant}Text`]]}>{children}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 200,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: '#f4511e',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#6200ee',
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderColor: '#f4511e',
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#fff',
|
||||
},
|
||||
secondaryText: {
|
||||
color: '#fff',
|
||||
},
|
||||
outlineText: {
|
||||
color: '#f4511e',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { TextInput, View, Text, StyleSheet } from 'react-native';
|
||||
|
||||
interface InputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
secureTextEntry?: boolean;
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input = ({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
secureTextEntry = false,
|
||||
label,
|
||||
error,
|
||||
}: InputProps) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{label && <Text style={styles.label}>{label}</Text>}
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
secureTextEntry={secureTextEntry}
|
||||
style={[styles.input, error && styles.inputError]}
|
||||
/>
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 8,
|
||||
color: '#000',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: '#ff0000',
|
||||
},
|
||||
error: {
|
||||
color: '#ff0000',
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Modal, TouchableOpacity, Text, StyleSheet, Platform } from 'react-native';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
onPress: () => void;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
items: ContextMenuItem[];
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({ visible, onClose, items, position }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<TouchableOpacity style={styles.overlay} onPress={onClose} activeOpacity={1}>
|
||||
<View
|
||||
style={[
|
||||
styles.menuContainer,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
top: position?.y || 0,
|
||||
left: position?.x || 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
index < items.length - 1 && styles.menuItemBorder,
|
||||
{ borderBottomColor: theme.colors.border },
|
||||
]}
|
||||
onPress={() => {
|
||||
item.onPress();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.menuItemText,
|
||||
{
|
||||
color: item.destructive ? theme.colors.error : theme.colors.textPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
menuContainer: {
|
||||
position: 'absolute',
|
||||
minWidth: 150,
|
||||
borderRadius: 8,
|
||||
...Platform.select({
|
||||
web: {
|
||||
boxShadow: '0px 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
default: {
|
||||
elevation: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
}),
|
||||
},
|
||||
menuItem: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
menuItemBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, View, Text, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface CreateItemButtonProps {
|
||||
onPress: () => void;
|
||||
variant?: 'card' | 'button';
|
||||
width?: number | 'auto';
|
||||
title?: string;
|
||||
buttonText?: string;
|
||||
icon?: keyof typeof MaterialIcons.glyphMap;
|
||||
buttonIcon?: keyof typeof MaterialIcons.glyphMap;
|
||||
}
|
||||
|
||||
export const CreateItemButton: React.FC<CreateItemButtonProps> = ({
|
||||
onPress,
|
||||
variant = 'card',
|
||||
width = 'auto',
|
||||
title = 'Create New Item',
|
||||
buttonText = 'Create New',
|
||||
icon = 'add',
|
||||
buttonIcon = 'add-circle-outline',
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.createButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<MaterialIcons name={buttonIcon} size={24} color="#FFFFFF" />
|
||||
<Text style={[styles.createButtonText, { color: '#FFFFFF' }]}>{buttonText}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width }, { backgroundColor: 'transparent' }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.itemContent}>
|
||||
<View
|
||||
style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name={icon} size={48} color={theme.colors.textPrimary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
itemContainer: {
|
||||
marginVertical: 0,
|
||||
marginHorizontal: 0,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
itemContent: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
imageContainer: {
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
textContainer: {
|
||||
padding: 0,
|
||||
marginTop: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
createButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
createButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Pressable } from 'react-native';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
import type { ColorMode, ContrastLevel } from '../ThemeProvider';
|
||||
|
||||
const COLOR_MODES: { label: string; value: ColorMode }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Hell', value: 'light' },
|
||||
{ label: 'Dunkel', value: 'dark' },
|
||||
];
|
||||
|
||||
const CONTRAST_LABELS: Record<ContrastLevel, string> = {
|
||||
1: 'Sehr niedrig',
|
||||
2: 'Niedrig',
|
||||
3: 'Standard',
|
||||
4: 'Hoch',
|
||||
5: 'Sehr hoch',
|
||||
};
|
||||
|
||||
export const ThemeSettings = () => {
|
||||
const { theme, colorMode, setColorMode, contrastLevel, setContrastLevel } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Helligkeits-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Helligkeit:</Text>
|
||||
<View style={styles.colorModeList}>
|
||||
{COLOR_MODES.map((mode) => (
|
||||
<TouchableOpacity
|
||||
key={mode.value}
|
||||
style={[
|
||||
styles.colorModeOption,
|
||||
{
|
||||
backgroundColor:
|
||||
mode.value === colorMode
|
||||
? `${theme.colors.primary}1A`
|
||||
: theme.colors.backgroundSecondary,
|
||||
borderColor: mode.value === colorMode ? theme.colors.primary : 'transparent',
|
||||
borderWidth: mode.value === colorMode ? 2 : 0,
|
||||
},
|
||||
]}
|
||||
onPress={() => setColorMode(mode.value)}
|
||||
>
|
||||
<Text style={[styles.colorModeText, { color: theme.colors.textPrimary }]}>
|
||||
{mode.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Kontrast-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Kontrast:</Text>
|
||||
<View style={styles.contrastContainer}>
|
||||
<View style={styles.contrastSlider}>
|
||||
{([1, 2, 3, 4, 5] as ContrastLevel[]).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
style={[
|
||||
styles.contrastOption,
|
||||
{
|
||||
backgroundColor:
|
||||
level === contrastLevel
|
||||
? theme.colors.primary
|
||||
: theme.colors.backgroundSecondary,
|
||||
},
|
||||
]}
|
||||
onPress={() => setContrastLevel(level)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.contrastLabel, { color: theme.colors.textPrimary }]}>
|
||||
{CONTRAST_LABELS[contrastLevel]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
gap: 16,
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
colorModeList: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
colorModeOption: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
colorModeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contrastContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
contrastSlider: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contrastOption: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
contrastLabel: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import * as ContextMenu from 'zeego/context-menu';
|
||||
|
||||
export const MenuRoot = ContextMenu.Root;
|
||||
export const MenuTrigger = ContextMenu.Trigger;
|
||||
export const MenuContent = ContextMenu.Content;
|
||||
export const MenuItem = ContextMenu.Item;
|
||||
export const MenuItemTitle = ContextMenu.ItemTitle;
|
||||
export const MenuSeparator = ContextMenu.Separator;
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, View, Text, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface CreateDeckButtonProps {
|
||||
onPress: () => void;
|
||||
variant?: 'card' | 'button';
|
||||
width?: number | 'auto';
|
||||
}
|
||||
|
||||
export const CreateDeckButton: React.FC<CreateDeckButtonProps> = ({
|
||||
onPress,
|
||||
variant = 'card',
|
||||
width = 'auto',
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.createButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<MaterialIcons name="add-circle-outline" size={24} color="#FFFFFF" />
|
||||
<Text style={[styles.createButtonText, { color: '#FFFFFF' }]}>Create your first deck</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.deckContainer, { width }, { backgroundColor: 'transparent' }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.deckContent}>
|
||||
<View
|
||||
style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="add" size={48} color={theme.colors.textPrimary} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Create New Deck</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deckContainer: {
|
||||
marginVertical: 8,
|
||||
marginHorizontal: 8,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
deckContent: {
|
||||
flex: 1,
|
||||
},
|
||||
imageContainer: {
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
textContainer: {
|
||||
padding: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
createButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
createButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Image } from 'react-native';
|
||||
import { Deck } from '../../types/models';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../components/ThemeProvider';
|
||||
|
||||
interface DeckCardProps {
|
||||
deck: Deck;
|
||||
onPress: (deck: Deck) => void;
|
||||
onDelete: (deck: Deck) => void;
|
||||
onShare: (deck: Deck) => void;
|
||||
firstSlideImage?: string;
|
||||
slideCount: number;
|
||||
}
|
||||
|
||||
export const DeckCard: React.FC<DeckCardProps> = ({
|
||||
deck,
|
||||
onPress,
|
||||
onDelete,
|
||||
onShare,
|
||||
firstSlideImage,
|
||||
slideCount,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
const isPublic = deck.sharing?.type === 'public';
|
||||
|
||||
const handleDelete = (event: any) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (typeof onDelete === 'function') {
|
||||
onDelete(deck);
|
||||
} else {
|
||||
console.warn('onDelete is not a function');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = (event: any) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (typeof onShare === 'function') {
|
||||
onShare(deck);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => onPress(deck)}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.imageContainer}>
|
||||
{firstSlideImage ? (
|
||||
<Image source={{ uri: firstSlideImage }} style={styles.thumbnail} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="image" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{deck.name}
|
||||
</Text>
|
||||
{isPublic && (
|
||||
<View style={[styles.badge, { backgroundColor: theme.colors.primary }]}>
|
||||
<Text style={styles.badgeText}>Public</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.slideCount, { color: theme.colors.textSecondary }]}>
|
||||
{slideCount} {slideCount === 1 ? 'Slide' : 'Slides'}
|
||||
</Text>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity onPress={handleShare} style={styles.actionButton}>
|
||||
<MaterialIcons name="share" size={24} color={theme.colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleDelete} style={styles.actionButton}>
|
||||
<MaterialIcons name="delete" size={24} color={theme.colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
imageContainer: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
header: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
slideCount: {
|
||||
fontSize: 14,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionButton: {
|
||||
padding: 4,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,443 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Image,
|
||||
useWindowDimensions,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Deck } from '../../types/models';
|
||||
import { useTheme } from '../../components/ThemeProvider';
|
||||
import { CreateItemButton } from '../common/CreateItemButton';
|
||||
import { MenuRoot, MenuTrigger, MenuContent, MenuItem, MenuItemTitle } from '../common/menu';
|
||||
|
||||
interface DeckListProps {
|
||||
decks: Deck[];
|
||||
onDeckPress: (deck: Deck) => void;
|
||||
onCreateDeck: () => void;
|
||||
onDeleteDeck: (deck: Deck) => void;
|
||||
onShareDeck: (deck: Deck) => void;
|
||||
firstSlideImages: { [key: string]: string };
|
||||
loading?: boolean;
|
||||
slideCounts: { [key: string]: number };
|
||||
scrollPadding?: {
|
||||
vertical?: { top?: number; bottom?: number };
|
||||
horizontal?: { top?: number; bottom?: number };
|
||||
};
|
||||
deckSpacing?: {
|
||||
vertical?: number;
|
||||
horizontal?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DeckList: React.FC<DeckListProps> = ({
|
||||
decks,
|
||||
onDeckPress,
|
||||
onCreateDeck,
|
||||
onDeleteDeck,
|
||||
onShareDeck,
|
||||
firstSlideImages,
|
||||
loading = false,
|
||||
slideCounts,
|
||||
scrollPadding = {
|
||||
vertical: { top: 0, bottom: 0 },
|
||||
horizontal: { top: 0, bottom: 0 },
|
||||
},
|
||||
deckSpacing = { vertical: 8, horizontal: 8 },
|
||||
}) => {
|
||||
const { width } = useWindowDimensions();
|
||||
const isSmallScreen = width < 768;
|
||||
const deckWidth = !isSmallScreen
|
||||
? Math.floor((width - 40 - deckSpacing.horizontal * 2) / 2.5)
|
||||
: 'auto';
|
||||
const { theme } = useTheme();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (decks.length === 0) {
|
||||
return (
|
||||
<View style={[styles.container, styles.emptyState]}>
|
||||
<MaterialIcons name="dashboard" size={48} color={theme.colors.textTertiary} />
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textSecondary }]}>
|
||||
No decks yet
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtext, { color: theme.colors.textTertiary }]}>
|
||||
Create your first deck to get started
|
||||
</Text>
|
||||
<CreateItemButton
|
||||
onPress={onCreateDeck}
|
||||
variant="button"
|
||||
title="Neues Deck erstellen"
|
||||
buttonText="Erstelle dein erstes Deck"
|
||||
icon="library-add"
|
||||
buttonIcon="library-add"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const renderDeck = ({ item }: { item: Deck | 'create' }) => {
|
||||
if (item === 'create') {
|
||||
return (
|
||||
<CreateItemButton
|
||||
onPress={onCreateDeck}
|
||||
variant="card"
|
||||
width={deckWidth}
|
||||
title="Neues Deck erstellen"
|
||||
buttonText="Neues Deck erstellen"
|
||||
icon="library-add"
|
||||
buttonIcon="library-add"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.deckContainer,
|
||||
!isSmallScreen ? { width: deckWidth } : {},
|
||||
{ backgroundColor: 'transparent' },
|
||||
]}
|
||||
onPress={() => onDeckPress(item)}
|
||||
>
|
||||
<View style={styles.deckContent}>
|
||||
<MenuRoot>
|
||||
<MenuTrigger>
|
||||
<View style={styles.deckInfoContainer}>
|
||||
<View style={styles.metaInfo}>
|
||||
<Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
|
||||
{new Date(item.updatedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
<View
|
||||
style={[styles.separator, { backgroundColor: theme.colors.borderPrimary }]}
|
||||
/>
|
||||
<Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>
|
||||
{slideCounts[item.id] || 0} {slideCounts[item.id] === 1 ? 'Slide' : 'Slides'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.imageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary },
|
||||
]}
|
||||
>
|
||||
{firstSlideImages[item.id] ? (
|
||||
<Image
|
||||
source={{ uri: firstSlideImages[item.id] }}
|
||||
style={styles.thumbnail}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="image" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.deckHeader, !isSmallScreen && styles.horizontalDeckHeader]}>
|
||||
<View
|
||||
style={[
|
||||
styles.titleContainer,
|
||||
!isSmallScreen && styles.horizontalTitleContainer,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.deckTitle,
|
||||
{ color: theme.colors.textPrimary },
|
||||
!isSmallScreen && { textAlign: 'center' },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.title || item.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<View
|
||||
style={[
|
||||
styles.menuContent,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItem onSelect={() => onShareDeck(item)} textValue="Share">
|
||||
<Pressable
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundTertiary
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="share"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.textPrimary }]}>
|
||||
Share
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => onDeleteDeck(item)} textValue="Delete">
|
||||
<Pressable
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundError
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="delete"
|
||||
size={18}
|
||||
color={theme.colors.error}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.error }]}>
|
||||
Delete
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
</View>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
style={[styles.list, { backgroundColor: 'transparent' }]}
|
||||
data={[...decks, 'create']}
|
||||
renderItem={renderDeck}
|
||||
keyExtractor={(item) => (item === 'create' ? 'create' : item.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
horizontal={!isSmallScreen}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: !isSmallScreen ? scrollPadding.horizontal.top : scrollPadding.vertical.top,
|
||||
paddingBottom: !isSmallScreen
|
||||
? scrollPadding.horizontal.bottom
|
||||
: scrollPadding.vertical.bottom,
|
||||
},
|
||||
!isSmallScreen && styles.horizontalListContent,
|
||||
]}
|
||||
scrollIndicatorInsets={{
|
||||
top: !isSmallScreen ? scrollPadding.horizontal.top : scrollPadding.vertical.top,
|
||||
bottom: !isSmallScreen ? scrollPadding.horizontal.bottom : scrollPadding.vertical.bottom,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
height: deckSpacing.vertical,
|
||||
width: deckSpacing.horizontal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
padding: 8,
|
||||
gap: 16,
|
||||
},
|
||||
horizontalListContent: {
|
||||
padding: 8,
|
||||
gap: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
deckContainer: {},
|
||||
deckContent: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
deckInfoContainer: {
|
||||
width: '100%',
|
||||
gap: 8,
|
||||
},
|
||||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: 4,
|
||||
gap: 8,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
textAlign: 'right',
|
||||
},
|
||||
separator: {
|
||||
width: 1,
|
||||
height: 12,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
deckHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
horizontalDeckHeader: {
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
gap: 8,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
horizontalTitleContainer: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
actionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 16,
|
||||
},
|
||||
horizontalActionsContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: 'auto',
|
||||
},
|
||||
deckTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
createDeckContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
createDeckText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptyState: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
},
|
||||
createButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
createButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
menuContent: {
|
||||
minWidth: 180,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
height: 44,
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
menuItemContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
menuItemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
TextInput,
|
||||
Platform,
|
||||
Clipboard,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Deck } from '../../types/models';
|
||||
import type { CollaboratorRole } from '../../types/models';
|
||||
|
||||
interface DeckShareSettingsProps {
|
||||
deck: Deck;
|
||||
onUpdateSharing: (sharing: Deck['sharing']) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CollaboratorInput {
|
||||
email: string;
|
||||
role: CollaboratorRole;
|
||||
}
|
||||
|
||||
export const DeckShareSettings: React.FC<DeckShareSettingsProps> = ({
|
||||
deck,
|
||||
onUpdateSharing,
|
||||
onClose,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [isPublic, setIsPublic] = useState(deck.sharing.isPublic);
|
||||
const [newCollaborator, setNewCollaborator] = useState<CollaboratorInput>({
|
||||
email: '',
|
||||
role: 'viewer',
|
||||
});
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const shareUrl = `${Platform.OS === 'web' ? window.location.origin : 'https://presi.app'}/deck/${deck.id}`;
|
||||
|
||||
const handleCopyLink = () => {
|
||||
Clipboard.setString(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleTogglePublic = () => {
|
||||
setIsPublic(!isPublic);
|
||||
onUpdateSharing({
|
||||
...deck.sharing,
|
||||
isPublic: !isPublic,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCollaborator = () => {
|
||||
// TODO: Implement email to userId lookup
|
||||
const mockUserId = 'user_' + Date.now();
|
||||
onUpdateSharing({
|
||||
...deck.sharing,
|
||||
collaborators: {
|
||||
...deck.sharing.collaborators,
|
||||
[mockUserId]: newCollaborator.role,
|
||||
},
|
||||
});
|
||||
setNewCollaborator({ email: '', role: 'viewer' });
|
||||
};
|
||||
|
||||
const handleRemoveCollaborator = (userId: string) => {
|
||||
const newCollaborators = { ...deck.sharing.collaborators };
|
||||
delete newCollaborators[userId];
|
||||
onUpdateSharing({
|
||||
...deck.sharing,
|
||||
collaborators: newCollaborators,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Share Settings</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingInfo}>
|
||||
<Text style={[styles.settingTitle, { color: theme.colors.textPrimary }]}>
|
||||
Public Access
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: theme.colors.textSecondary }]}>
|
||||
Anyone with the link can view this deck
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
onValueChange={handleTogglePublic}
|
||||
trackColor={{ false: theme.colors.backgroundSecondary, true: theme.colors.primary }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, styles.linkSection]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>
|
||||
Share Link
|
||||
</Text>
|
||||
<View
|
||||
style={[styles.linkContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<Text style={[styles.link, { color: theme.colors.textPrimary }]} numberOfLines={1}>
|
||||
{shareUrl}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.copyButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleCopyLink}
|
||||
>
|
||||
<MaterialIcons name={copied ? 'check' : 'content-copy'} size={20} color="#FFFFFF" />
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied!' : 'Copy'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Collaborators
|
||||
</Text>
|
||||
<View style={styles.collaboratorInput}>
|
||||
<TextInput
|
||||
style={[styles.input, { color: theme.colors.textPrimary }]}
|
||||
placeholder="Email address"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
value={newCollaborator.email}
|
||||
onChangeText={(email) => setNewCollaborator({ ...newCollaborator, email })}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleAddCollaborator}
|
||||
>
|
||||
<Text style={styles.addButtonText}>Add</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{Object.entries(deck.sharing.collaborators).map(([userId, role]) => (
|
||||
<View key={userId} style={styles.collaboratorRow}>
|
||||
<Text style={[styles.collaboratorEmail, { color: theme.colors.textPrimary }]}>
|
||||
{userId} ({role})
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => handleRemoveCollaborator(userId)}>
|
||||
<MaterialIcons name="remove-circle" size={24} color={theme.colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
maxWidth: 500,
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#ccc',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
gap: 24,
|
||||
},
|
||||
section: {
|
||||
gap: 16,
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
settingInfo: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
linkSection: {
|
||||
gap: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
linkContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
gap: 8,
|
||||
},
|
||||
link: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
gap: 4,
|
||||
},
|
||||
copyButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
collaboratorInput: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
addButton: {
|
||||
paddingHorizontal: 16,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
},
|
||||
addButtonText: {
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
collaboratorRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
collaboratorEmail: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
import { createDeck } from '../../services/firestore';
|
||||
import { Deck } from '../../types/models';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
interface CreateDeckFormProps {
|
||||
onSuccess: (deck: Deck) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CreateDeckForm: React.FC<CreateDeckFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { theme } = useTheme();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Please enter a name for your deck');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newDeck = await createDeck({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
onSuccess(newDeck);
|
||||
} catch (err) {
|
||||
setError('Failed to create deck. Please try again.');
|
||||
console.error('Error creating deck:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={[styles.container, { backgroundColor: theme.colors.backgroundPrimary }]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>Create New Deck</Text>
|
||||
<TouchableOpacity onPress={onCancel} style={styles.closeButton}>
|
||||
<MaterialIcons name="close" size={24} color={theme.colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Name</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Enter deck name"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>
|
||||
Description (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Enter deck description"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && <Text style={[styles.error, { color: theme.colors.error }]}>{error}</Text>}
|
||||
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.cancelButton,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary },
|
||||
]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.createButton, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: '#FFFFFF' }]}>Create Deck</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
form: {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
error: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cancelButton: {},
|
||||
createButton: {},
|
||||
});
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
Image,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SlideView } from '../slides/SlideView';
|
||||
import { Slide } from '../../types/models';
|
||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface PresentationModeProps {
|
||||
slides: Slide[];
|
||||
initialSlideIndex?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const PresentationMode: React.FC<PresentationModeProps> = ({
|
||||
slides,
|
||||
initialSlideIndex = 0,
|
||||
onClose,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [currentSlideIndex, setCurrentSlideIndex] = useState(initialSlideIndex);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [showSpeakerNotes, setShowSpeakerNotes] = useState(false);
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
// Control visibility state
|
||||
const controlsOpacity = useRef(new Animated.Value(1)).current;
|
||||
const hideControlsTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
// Clear any existing timer
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
|
||||
// Show controls with animation
|
||||
Animated.timing(controlsOpacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
// Set timer to hide controls after 5 seconds
|
||||
hideControlsTimer.current = setTimeout(() => {
|
||||
Animated.timing(controlsOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, 5000);
|
||||
}, [controlsOpacity]);
|
||||
|
||||
const handleNavigation = useCallback(
|
||||
(direction: 'prev' | 'next') => {
|
||||
if (direction === 'prev') {
|
||||
setCurrentSlideIndex((prev) => (prev === 0 ? slides.length - 1 : prev - 1));
|
||||
} else {
|
||||
setCurrentSlideIndex((prev) => (prev === slides.length - 1 ? 0 : prev + 1));
|
||||
}
|
||||
showControls();
|
||||
},
|
||||
[slides.length, showControls]
|
||||
);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
console.log('Key pressed:', event.key); // Debug log
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'arrowleft':
|
||||
case 'a':
|
||||
event.preventDefault();
|
||||
handleNavigation('prev');
|
||||
break;
|
||||
case 'arrowright':
|
||||
case 'd':
|
||||
event.preventDefault();
|
||||
handleNavigation('next');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [handleNavigation]);
|
||||
|
||||
// Handle mouse movement
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
window.addEventListener('mousemove', showControls);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', showControls);
|
||||
};
|
||||
}
|
||||
}, [showControls]);
|
||||
|
||||
// Show controls initially
|
||||
useEffect(() => {
|
||||
showControls();
|
||||
}, []);
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isTimerRunning) {
|
||||
timer = setInterval(() => {
|
||||
setElapsedTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(timer);
|
||||
}, [isTimerRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
const setupOrientation = async () => {
|
||||
if (isFullscreen) {
|
||||
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
||||
} else {
|
||||
await ScreenOrientation.unlockAsync();
|
||||
}
|
||||
};
|
||||
|
||||
if (Platform.OS !== 'web') {
|
||||
setupOrientation();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (Platform.OS !== 'web') {
|
||||
ScreenOrientation.unlockAsync();
|
||||
}
|
||||
};
|
||||
}, [isFullscreen]);
|
||||
|
||||
const currentSlide = slides[currentSlideIndex];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.backgroundPage }]}>
|
||||
{/* Top bar with title and close button */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.topBar,
|
||||
{
|
||||
opacity: controlsOpacity,
|
||||
backgroundColor: `${theme.colors.backgroundPrimary}CC`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.slideTitle, { color: theme.colors.textPrimary }]}>
|
||||
{currentSlide.title}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.closeButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Current slide */}
|
||||
<View style={styles.slideContainer}>
|
||||
<SlideView
|
||||
slide={currentSlide}
|
||||
showNotes={showSpeakerNotes}
|
||||
isFullscreen={isFullscreen}
|
||||
onNavigate={handleNavigation}
|
||||
isFirstSlide={currentSlideIndex === 0}
|
||||
isLastSlide={currentSlideIndex === slides.length - 1}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Controls overlay with animation */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.controlsOverlay,
|
||||
{
|
||||
opacity: controlsOpacity,
|
||||
backgroundColor: `${theme.colors.backgroundPrimary}CC`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => handleNavigation('prev')}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.centerControls}>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => setShowSpeakerNotes(!showSpeakerNotes)}
|
||||
>
|
||||
<Ionicons
|
||||
name={showSpeakerNotes ? 'eye-off' : 'eye'}
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => setIsTimerRunning(!isTimerRunning)}
|
||||
>
|
||||
<Ionicons
|
||||
name={isTimerRunning ? 'pause' : 'play'}
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.timer, { color: theme.colors.textPrimary }]}>
|
||||
{Math.floor(elapsedTime / 60)}:{(elapsedTime % 60).toString().padStart(2, '0')}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => setIsFullscreen(!isFullscreen)}
|
||||
>
|
||||
<Ionicons
|
||||
name={isFullscreen ? 'contract' : 'expand'}
|
||||
size={24}
|
||||
color={theme.colors.textPrimary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={() => handleNavigation('next')}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={24} color={theme.colors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 40,
|
||||
zIndex: 10,
|
||||
},
|
||||
slideTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '500',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
slideContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlsOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 16,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
maxWidth: 800,
|
||||
marginHorizontal: 'auto',
|
||||
},
|
||||
centerControls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
controlButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
timer: {
|
||||
fontSize: 16,
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,336 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Alert,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { storage } from '../../firebaseConfig';
|
||||
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
||||
import { createSlide, updateSlide } from '../../services/firestore';
|
||||
import { Slide } from '../../types/models';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface SlideEditorProps {
|
||||
deckId: string;
|
||||
slide?: Slide;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export const SlideEditor: React.FC<SlideEditorProps> = ({ deckId, slide, onSuccess, onCancel }) => {
|
||||
const { theme } = useTheme();
|
||||
const [title, setTitle] = useState(slide?.title ?? '');
|
||||
const [fullText, setFullText] = useState(slide?.fullText ?? '');
|
||||
const [bulletPoints, setBulletPoints] = useState<string[]>(slide?.bulletPoints ?? ['']);
|
||||
const [notes, setNotes] = useState(slide?.notes ?? '');
|
||||
const [imageUrl, setImageUrl] = useState(slide?.imageUrl);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleAddBulletPoint = () => {
|
||||
setBulletPoints([...bulletPoints, '']);
|
||||
};
|
||||
|
||||
const handleUpdateBulletPoint = (index: number, text: string) => {
|
||||
const newBulletPoints = [...bulletPoints];
|
||||
newBulletPoints[index] = text;
|
||||
setBulletPoints(newBulletPoints);
|
||||
};
|
||||
|
||||
const handleRemoveBulletPoint = (index: number) => {
|
||||
const newBulletPoints = bulletPoints.filter((_, i) => i !== index);
|
||||
setBulletPoints(newBulletPoints);
|
||||
};
|
||||
|
||||
const handlePickImage = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (permissionResult.granted === false) {
|
||||
Alert.alert('Permission Required', 'Please allow access to your photos to upload images.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [16, 9],
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
const uri = result.assets[0].uri;
|
||||
setImageUrl(uri);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let uploadedImageUrl = imageUrl;
|
||||
|
||||
// Upload image if selected
|
||||
if (imageUrl && imageUrl.startsWith('file://')) {
|
||||
const response = await fetch(imageUrl);
|
||||
const blob = await response.blob();
|
||||
const imagePath = `slides/${deckId}/${Date.now()}.jpg`;
|
||||
const imageRef = ref(storage, imagePath);
|
||||
await uploadBytes(imageRef, blob);
|
||||
uploadedImageUrl = await getDownloadURL(imageRef);
|
||||
}
|
||||
|
||||
// Create or update slide
|
||||
const slideData = {
|
||||
deckId,
|
||||
title,
|
||||
fullText,
|
||||
bulletPoints: bulletPoints.filter((bp) => bp.trim() !== ''),
|
||||
notes,
|
||||
imageUrl: uploadedImageUrl,
|
||||
};
|
||||
|
||||
if (slide?.id) {
|
||||
// Update existing slide
|
||||
await updateSlide(slide.id, slideData);
|
||||
} else {
|
||||
// Create new slide
|
||||
const newSlide = await createSlide(slideData);
|
||||
console.log('[SlideEditor] Slide created successfully:', newSlide);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
console.error('[SlideEditor] Error saving slide:', error);
|
||||
Alert.alert('Error', 'Failed to save slide. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.editorContainer, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<ScrollView style={{ flex: 1, padding: 16 }}>
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>Title</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
},
|
||||
]}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Enter slide title"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Image</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.imagePreview,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
onPress={handlePickImage}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image source={{ uri: imageUrl }} style={styles.imagePreview} resizeMode="cover" />
|
||||
) : (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<MaterialIcons name="add-photo-alternate" size={32} color={theme.colors.primary} />
|
||||
<Text style={[styles.addButtonText, { color: theme.colors.primary }]}>
|
||||
Add Image
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Full Text</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
value={fullText}
|
||||
onChangeText={setFullText}
|
||||
placeholder="Enter full text content..."
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>Bullet Points</Text>
|
||||
{bulletPoints.map((point, index) => (
|
||||
<View key={index} style={styles.bulletPointContainer}>
|
||||
<MaterialIcons
|
||||
name="circle"
|
||||
size={8}
|
||||
color={theme.colors.textSecondary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.bulletPointInput,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
value={point}
|
||||
onChangeText={(text) => handleUpdateBulletPoint(index, text)}
|
||||
placeholder="Add bullet point..."
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
multiline
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={{ padding: 4 }}
|
||||
onPress={() => handleRemoveBulletPoint(index)}
|
||||
>
|
||||
<MaterialIcons name="remove-circle-outline" size={20} color={theme.colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
<TouchableOpacity style={styles.addButton} onPress={handleAddBulletPoint}>
|
||||
<MaterialIcons name="add-circle-outline" size={20} color={theme.colors.primary} />
|
||||
<Text style={[styles.addButtonText, { color: theme.colors.primary }]}>
|
||||
Add Bullet Point
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>Notes</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundSecondary,
|
||||
color: theme.colors.textPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
},
|
||||
]}
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Add presenter notes"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textPrimary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: theme.colors.primary }]}
|
||||
onPress={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: theme.colors.textOnPrimary }]}>
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
editorContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
formGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
textArea: {
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
bulletPointContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
bulletPointInput: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
addButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
addButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
imagePreview: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
marginTop: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,426 +0,0 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Image,
|
||||
useWindowDimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { Slide } from '../../types/models';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
import { MenuRoot, MenuTrigger, MenuContent, MenuItem, MenuItemTitle } from '../common/menu';
|
||||
|
||||
interface SlideListProps {
|
||||
slides: Slide[];
|
||||
showNotes?: boolean;
|
||||
onEditSlide?: (slide: Slide) => void;
|
||||
onCreateSlide?: () => void;
|
||||
onDeleteSlide?: (slide: Slide) => void;
|
||||
onMoveSlide?: (slide: Slide, direction: 'up' | 'down') => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const SlideList: React.FC<SlideListProps> = ({
|
||||
slides,
|
||||
showNotes = false,
|
||||
onEditSlide,
|
||||
onCreateSlide,
|
||||
onDeleteSlide,
|
||||
onMoveSlide,
|
||||
loading,
|
||||
}) => {
|
||||
const { width } = useWindowDimensions();
|
||||
const { theme } = useTheme();
|
||||
const isSmallScreen = width < 768; // Tablet breakpoint
|
||||
|
||||
// Calculate slide width to show 2.5 slides
|
||||
const slideWidth = !isSmallScreen ? Math.floor(width / 2.5) : 'auto';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.emptyState, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<MaterialIcons name="hourglass-empty" size={48} color={theme.colors.textSecondary} />
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textSecondary }]}>
|
||||
Loading...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const renderItem = ({ item, index }: { item: Slide | 'create'; index: number }) => {
|
||||
if (item === 'create') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.slideContainer,
|
||||
{
|
||||
width: slideWidth,
|
||||
},
|
||||
]}
|
||||
onPress={onCreateSlide}
|
||||
>
|
||||
<View style={styles.slideContent}>
|
||||
<View
|
||||
style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons
|
||||
name="add-photo-alternate"
|
||||
size={48}
|
||||
color={theme.colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.slideFooter}>
|
||||
<Text style={[styles.slideNumber, { color: theme.colors.textPrimary }]}>
|
||||
Neuen Slide erstellen
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.slideContainer,
|
||||
{
|
||||
width: slideWidth,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuRoot>
|
||||
<MenuTrigger>
|
||||
<TouchableOpacity onPress={() => onEditSlide?.(item)} style={{ flex: 1 }}>
|
||||
<View style={styles.slideContent}>
|
||||
<View
|
||||
style={[
|
||||
styles.imageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundSecondary },
|
||||
]}
|
||||
>
|
||||
{item.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: item.imageUrl }}
|
||||
style={styles.thumbnail}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons name="image" size={48} color={theme.colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.slideFooter}>
|
||||
<Text style={[styles.slideNumber, { color: theme.colors.textPrimary }]}>
|
||||
{item.title || `Slide ${index + 1}`}
|
||||
</Text>
|
||||
</View>
|
||||
{showNotes && item.notes && (
|
||||
<Text
|
||||
style={[styles.notes, { color: theme.colors.textSecondary }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.notes}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<View
|
||||
style={[
|
||||
styles.menuContent,
|
||||
{
|
||||
backgroundColor: theme.colors.backgroundPrimary,
|
||||
borderColor: theme.colors.borderPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{onMoveSlide && index > 0 && (
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
console.log('[SlideList] Selected Move Up for slide:', item.id);
|
||||
onMoveSlide(item, 'up');
|
||||
}}
|
||||
textValue="Move Up"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log('[SlideList] Pressed Move Up for slide:', item.id);
|
||||
onMoveSlide(item, 'up');
|
||||
}}
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundTertiary
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="arrow-upward"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.textPrimary }]}>
|
||||
Nach oben
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
)}
|
||||
{onMoveSlide && index < slides.length - 1 && (
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
console.log('[SlideList] Selected Move Down for slide:', item.id);
|
||||
onMoveSlide(item, 'down');
|
||||
}}
|
||||
textValue="Move Down"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log('[SlideList] Pressed Move Down for slide:', item.id);
|
||||
onMoveSlide(item, 'down');
|
||||
}}
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundTertiary
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="arrow-downward"
|
||||
size={18}
|
||||
color={theme.colors.textPrimary}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.textPrimary }]}>
|
||||
Nach unten
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
)}
|
||||
{onDeleteSlide && (
|
||||
<MenuItem onSelect={() => onDeleteSlide(item)} textValue="Delete">
|
||||
<Pressable
|
||||
onPress={() => onDeleteSlide(item)}
|
||||
style={({ hovered }) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
backgroundColor: hovered
|
||||
? theme.colors.backgroundError
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MenuItemTitle>
|
||||
<View style={styles.menuItemContent}>
|
||||
<MaterialIcons
|
||||
name="delete"
|
||||
size={18}
|
||||
color={theme.colors.error}
|
||||
style={styles.menuItemIcon}
|
||||
/>
|
||||
<Text style={[styles.menuItemTitle, { color: theme.colors.error }]}>
|
||||
Löschen
|
||||
</Text>
|
||||
</View>
|
||||
</MenuItemTitle>
|
||||
</Pressable>
|
||||
</MenuItem>
|
||||
)}
|
||||
</View>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (slides.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyState}>
|
||||
<MaterialIcons name="slideshow" size={48} color={theme.colors.textSecondary} />
|
||||
<Text style={[styles.emptyStateText, { color: theme.colors.textSecondary }]}>
|
||||
Keine Slides
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtext, { color: theme.colors.textSecondary }]}>
|
||||
Erstelle deinen ersten Slide
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.slideContainer,
|
||||
{
|
||||
width: slideWidth,
|
||||
},
|
||||
]}
|
||||
onPress={onCreateSlide}
|
||||
>
|
||||
<View style={styles.slideContent}>
|
||||
<View
|
||||
style={[styles.imageContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<View style={styles.placeholderContainer}>
|
||||
<MaterialIcons
|
||||
name="add-photo-alternate"
|
||||
size={48}
|
||||
color={theme.colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.slideFooter}>
|
||||
<Text style={[styles.slideNumber, { color: theme.colors.textPrimary }]}>
|
||||
Erstelle deinen ersten Slide
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={[...slides, 'create']}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => (item === 'create' ? 'create' : item.id)}
|
||||
horizontal={!isSmallScreen}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[styles.listContent, !isSmallScreen && { paddingHorizontal: 16 }]}
|
||||
extraData={slides.map((s) => s.order).join(',')}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
horizontalListContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 16,
|
||||
gap: 16,
|
||||
alignItems: 'center',
|
||||
minHeight: '100%',
|
||||
},
|
||||
slideContainer: {
|
||||
marginHorizontal: 16,
|
||||
marginVertical: 8,
|
||||
},
|
||||
slideContent: {
|
||||
gap: 8,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
slideFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 0,
|
||||
},
|
||||
slideNumber: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
actionButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
notes: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 32,
|
||||
},
|
||||
menuContent: {
|
||||
minWidth: 180,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
menuItem: {
|
||||
height: 44,
|
||||
paddingHorizontal: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
menuItemContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
menuItemIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
menuItemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Image, StyleSheet, Dimensions, TouchableOpacity } from 'react-native';
|
||||
import { Slide } from '../../types/models';
|
||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
interface SlideViewProps {
|
||||
slide: Slide;
|
||||
showNotes?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
onToggleFullscreen?: () => void;
|
||||
onNavigate?: (direction: 'prev' | 'next') => void;
|
||||
isFirstSlide?: boolean;
|
||||
isLastSlide?: boolean;
|
||||
}
|
||||
|
||||
export const SlideView: React.FC<SlideViewProps> = ({
|
||||
slide,
|
||||
showNotes = false,
|
||||
isFullscreen = false,
|
||||
onToggleFullscreen,
|
||||
onNavigate,
|
||||
isFirstSlide = false,
|
||||
isLastSlide = false,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
isFullscreen && styles.fullscreenContainer,
|
||||
{
|
||||
backgroundColor: isFullscreen
|
||||
? theme.colors.backgroundPage
|
||||
: theme.colors.backgroundPrimary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{slide.imageUrl && (
|
||||
<View
|
||||
style={[
|
||||
styles.imageContainer,
|
||||
isFullscreen && styles.fullscreenImageContainer,
|
||||
{ backgroundColor: theme.colors.backgroundPage },
|
||||
]}
|
||||
>
|
||||
{/* Navigation Areas */}
|
||||
{!isFirstSlide && (
|
||||
<TouchableOpacity style={styles.navigationArea} onPress={() => onNavigate?.('prev')} />
|
||||
)}
|
||||
{!isLastSlide && (
|
||||
<TouchableOpacity
|
||||
style={[styles.navigationArea, styles.navigationAreaRight]}
|
||||
onPress={() => onNavigate?.('next')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<Image
|
||||
source={{ uri: slide.imageUrl }}
|
||||
style={styles.image}
|
||||
resizeMode={isFullscreen ? 'contain' : 'cover'}
|
||||
/>
|
||||
|
||||
{/* Navigation Indicators */}
|
||||
{isFullscreen && (
|
||||
<>
|
||||
{!isFirstSlide && (
|
||||
<View style={[styles.navigationIndicator, styles.navigationIndicatorLeft]}>
|
||||
<MaterialIcons
|
||||
name="chevron-left"
|
||||
size={36}
|
||||
color={`${theme.colors.textPrimary}80`}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{!isLastSlide && (
|
||||
<View style={[styles.navigationIndicator, styles.navigationIndicatorRight]}>
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={36}
|
||||
color={`${theme.colors.textPrimary}80`}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isFullscreen && (
|
||||
<View style={styles.content}>
|
||||
<Text style={[styles.contentTitle, { color: theme.colors.textPrimary }]}>
|
||||
{slide.title}
|
||||
</Text>
|
||||
|
||||
{slide.bulletPoints && slide.bulletPoints.length > 0 && (
|
||||
<View style={styles.bulletPoints}>
|
||||
{slide.bulletPoints.map((point, index) => (
|
||||
<View key={index} style={styles.bulletPoint}>
|
||||
<Text style={[styles.bullet, { color: theme.colors.textPrimary }]}>•</Text>
|
||||
<Text style={[styles.bulletText, { color: theme.colors.textPrimary }]}>
|
||||
{point}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{slide.fullText && (
|
||||
<Text style={[styles.fullText, { color: theme.colors.textPrimary }]}>
|
||||
{slide.fullText}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{showNotes && slide.notes && (
|
||||
<View
|
||||
style={[styles.notesContainer, { backgroundColor: theme.colors.backgroundSecondary }]}
|
||||
>
|
||||
<Text style={[styles.notesTitle, { color: theme.colors.textSecondary }]}>Notes:</Text>
|
||||
<Text style={[styles.notes, { color: theme.colors.textPrimary }]}>{slide.notes}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const ASPECT_RATIO = 16 / 9;
|
||||
const SLIDE_WIDTH = width;
|
||||
const SLIDE_HEIGHT = SLIDE_WIDTH / ASPECT_RATIO;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: SLIDE_WIDTH,
|
||||
height: SLIDE_HEIGHT,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fullscreenContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 0,
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
aspectRatio: 16 / 9,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
fullscreenImageContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
aspectRatio: undefined,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
},
|
||||
navigationArea: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '50%',
|
||||
height: '100%',
|
||||
zIndex: 2,
|
||||
},
|
||||
navigationAreaRight: {
|
||||
left: '50%',
|
||||
},
|
||||
navigationIndicator: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: [{ translateY: -18 }],
|
||||
opacity: 0.5,
|
||||
zIndex: 1,
|
||||
},
|
||||
navigationIndicatorLeft: {
|
||||
left: 16,
|
||||
},
|
||||
navigationIndicatorRight: {
|
||||
right: 16,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
},
|
||||
bulletPoints: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
bulletPoint: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
bullet: {
|
||||
fontSize: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
bulletText: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
fullText: {
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
notesContainer: {
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
notesTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
notes: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
import { ColorSchemeName } from 'react-native';
|
||||
|
||||
// Theme types
|
||||
export type ThemeVariant = 'lume' | 'nature' | 'stone';
|
||||
|
||||
export const THEME_PATTERNS: Record<ThemeVariant, any> = {
|
||||
lume: require('../assets/images/patterns/memo-theme-tile.png'),
|
||||
nature: require('../assets/images/patterns/nature-theme-tile.png'),
|
||||
stone: require('../assets/images/patterns/stone-theme-tile.png'),
|
||||
};
|
||||
|
||||
export const THEME_NAMES: Record<ThemeVariant, string> = {
|
||||
lume: 'Lume',
|
||||
nature: 'Nature',
|
||||
stone: 'Stone',
|
||||
};
|
||||
|
||||
// Farbpalette
|
||||
export const lightColors = {
|
||||
primary: '#f8d62b',
|
||||
backgroundForPrimary: '#383838',
|
||||
primaryHover: '#2980b9',
|
||||
primaryPressed: '#1f6da3',
|
||||
secondary: '#D4B200',
|
||||
secondaryHover: '#27ae60',
|
||||
secondaryPressed: '#229954',
|
||||
backgroundPage: '#dddddd',
|
||||
backgroundPrimary: '#ffffff',
|
||||
backgroundSecondary: '#eeeeee', // Geändert zu einem helleren Grau
|
||||
backgroundTertiary: '#e8e8e8', // Neue Farbe für Hover-Zustand
|
||||
backgroundError: '#FFEBEE', // Helles Rot für Fehlerhintergrund
|
||||
textPrimary: '#000000',
|
||||
textSecondary: '#666666',
|
||||
textTertiary: '#999999',
|
||||
borderPrimary: '#bbbbbb',
|
||||
borderSecondary: '#282828',
|
||||
textOnPrimary: '#000000', // Schwarz für Text auf primärer Farbe
|
||||
error: '#e74c3c', // Rot für Gefahren-Buttons
|
||||
backgroundFree: '#95a5a6', // Grau
|
||||
backgroundPlus: '#f39c12', // Orange
|
||||
backgroundPro: '#f8d62b', // Gelb
|
||||
backgroundUltra: '#e74c3c', // Rot
|
||||
};
|
||||
|
||||
export const darkColors = {
|
||||
primary: '#f8d62b',
|
||||
backgroundForPrimary: '#383838',
|
||||
primaryHover: '#f8d62b',
|
||||
primaryPressed: '#1f6da3',
|
||||
secondary: '#D4B200',
|
||||
secondaryHover: '#27ae60',
|
||||
secondaryPressed: '#229954',
|
||||
backgroundPage: '#121212',
|
||||
backgroundPrimary: '#1e1e1e',
|
||||
backgroundSecondary: '#2c2c2c',
|
||||
backgroundTertiary: '#383838', // Neue Farbe für Hover-Zustand
|
||||
backgroundError: '#260000', // Dunkles Rot für Fehlerhintergrund
|
||||
textPrimary: '#ffffff',
|
||||
textSecondary: '#cccccc',
|
||||
textTertiary: '#999999',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#424242',
|
||||
borderSecondary: '#282828',
|
||||
error: '#e74c3c', // Rot für Gefahren-Buttons
|
||||
backgroundFree: '#95a5a6', // Grau
|
||||
backgroundPlus: '#f39c12', // Orange
|
||||
backgroundPro: '#f8d62b', // Gelb
|
||||
backgroundUltra: '#e74c3c', // Rot
|
||||
};
|
||||
|
||||
// Nature theme colors
|
||||
export const natureLightColors = {
|
||||
primary: '#81C784',
|
||||
backgroundForPrimary: '#2E7D32',
|
||||
primaryHover: '#66BB6A',
|
||||
primaryPressed: '#4CAF50',
|
||||
secondary: '#A5D6A7',
|
||||
secondaryHover: '#81C784',
|
||||
secondaryPressed: '#66BB6A',
|
||||
backgroundPage: '#F1F8E9',
|
||||
backgroundPrimary: '#FFFFFF',
|
||||
backgroundSecondary: '#F9FBE7',
|
||||
backgroundTertiary: '#F0F4C3',
|
||||
backgroundError: '#FFEBEE',
|
||||
textPrimary: '#1B5E20',
|
||||
textSecondary: '#33691E',
|
||||
textTertiary: '#558B2F',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#C8E6C9',
|
||||
borderSecondary: '#A5D6A7',
|
||||
error: '#E57373',
|
||||
backgroundFree: '#AED581',
|
||||
backgroundPlus: '#9CCC65',
|
||||
backgroundPro: '#8BC34A',
|
||||
backgroundUltra: '#7CB342',
|
||||
};
|
||||
|
||||
export const natureDarkColors = {
|
||||
primary: '#81C784',
|
||||
backgroundForPrimary: '#2E7D32',
|
||||
primaryHover: '#66BB6A',
|
||||
primaryPressed: '#4CAF50',
|
||||
secondary: '#A5D6A7',
|
||||
secondaryHover: '#81C784',
|
||||
secondaryPressed: '#66BB6A',
|
||||
backgroundPage: '#1B1B1B',
|
||||
backgroundPrimary: '#1E1E1E',
|
||||
backgroundSecondary: '#2C2C2C',
|
||||
backgroundTertiary: '#333333',
|
||||
backgroundError: '#CF6679',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#C8E6C9',
|
||||
textTertiary: '#A5D6A7',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#2E7D32',
|
||||
borderSecondary: '#1B5E20',
|
||||
error: '#CF6679',
|
||||
backgroundFree: '#558B2F',
|
||||
backgroundPlus: '#7CB342',
|
||||
backgroundPro: '#8BC34A',
|
||||
backgroundUltra: '#9CCC65',
|
||||
};
|
||||
|
||||
// Stone theme colors
|
||||
export const stoneLightColors = {
|
||||
primary: '#90A4AE',
|
||||
backgroundForPrimary: '#455A64',
|
||||
primaryHover: '#78909C',
|
||||
primaryPressed: '#607D8B',
|
||||
secondary: '#B0BEC5',
|
||||
secondaryHover: '#90A4AE',
|
||||
secondaryPressed: '#78909C',
|
||||
backgroundPage: '#ECEFF1',
|
||||
backgroundPrimary: '#FFFFFF',
|
||||
backgroundSecondary: '#F5F5F5',
|
||||
backgroundTertiary: '#EEEEEE',
|
||||
backgroundError: '#FFEBEE',
|
||||
textPrimary: '#263238',
|
||||
textSecondary: '#37474F',
|
||||
textTertiary: '#455A64',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#CFD8DC',
|
||||
borderSecondary: '#B0BEC5',
|
||||
error: '#EF5350',
|
||||
backgroundFree: '#90A4AE',
|
||||
backgroundPlus: '#78909C',
|
||||
backgroundPro: '#607D8B',
|
||||
backgroundUltra: '#546E7A',
|
||||
};
|
||||
|
||||
export const stoneDarkColors = {
|
||||
primary: '#90A4AE',
|
||||
backgroundForPrimary: '#455A64',
|
||||
primaryHover: '#78909C',
|
||||
primaryPressed: '#607D8B',
|
||||
secondary: '#B0BEC5',
|
||||
secondaryHover: '#90A4AE',
|
||||
secondaryPressed: '#78909C',
|
||||
backgroundPage: '#121212',
|
||||
backgroundPrimary: '#1A1A1A',
|
||||
backgroundSecondary: '#242424',
|
||||
backgroundTertiary: '#2C2C2C',
|
||||
backgroundError: '#CF6679',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#B0BEC5',
|
||||
textTertiary: '#90A4AE',
|
||||
textOnPrimary: '#000000',
|
||||
borderPrimary: '#455A64',
|
||||
borderSecondary: '#37474F',
|
||||
error: '#CF6679',
|
||||
backgroundFree: '#546E7A',
|
||||
backgroundPlus: '#607D8B',
|
||||
backgroundPro: '#78909C',
|
||||
backgroundUltra: '#90A4AE',
|
||||
};
|
||||
|
||||
// Schriftgrößen
|
||||
const fontSizes = {
|
||||
small: 14,
|
||||
body: 16,
|
||||
subtitle: 18,
|
||||
title: 20,
|
||||
h1: 28, // Neue Zeile für h1
|
||||
h2: 24, // Neue Zeile für h2
|
||||
};
|
||||
|
||||
// Schriftstärken
|
||||
const fontWeights = {
|
||||
regular: '400',
|
||||
medium: '500',
|
||||
bold: '700',
|
||||
};
|
||||
|
||||
// Abstände
|
||||
const spacing = {
|
||||
none: 0,
|
||||
xxs: 2, // Neuer Wert für sehr kleine Abstände
|
||||
xsmall: 4,
|
||||
small: 8,
|
||||
medium: 12,
|
||||
large: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
xxxl: 64,
|
||||
horizontalPageMargin: 16,
|
||||
verticalPageMargin: 24,
|
||||
sectionSpacing: 40,
|
||||
elementSpacing: 20,
|
||||
inlineElementSpacing: 12,
|
||||
cardPadding: 16,
|
||||
headerHeight: 60,
|
||||
};
|
||||
|
||||
// Rundungen
|
||||
const borderRadius = {
|
||||
small: 4,
|
||||
medium: 12,
|
||||
large: 16,
|
||||
round: 9999,
|
||||
};
|
||||
|
||||
// Schatten aktualisieren
|
||||
const shadows = {
|
||||
small: '0px 2px 4px 0px rgba(0, 0, 0, 0.1)',
|
||||
medium: '0px 4px 8px 0px rgba(0, 0, 0, 0.15)',
|
||||
large: '0px 6px 12px 0px rgba(0, 0, 0, 0.2)',
|
||||
};
|
||||
|
||||
// Z-Index-Werte
|
||||
const zIndex = {
|
||||
base: 1,
|
||||
dropdown: 1000,
|
||||
modal: 2000,
|
||||
tooltip: 3000,
|
||||
};
|
||||
|
||||
// Neue TagColors mit englischen Namen
|
||||
export const tagColors = {
|
||||
blue: '#3498db',
|
||||
green: '#2ecc71',
|
||||
red: '#e74c3c',
|
||||
orange: '#f39c12',
|
||||
purple: '#9b59b6',
|
||||
teal: '#1abc9c',
|
||||
pink: '#e84393',
|
||||
gray: '#95a5a6',
|
||||
};
|
||||
|
||||
// Updated getTheme function to support multiple themes
|
||||
export const getTheme = (colorScheme: ColorSchemeName, themeVariant: ThemeVariant = 'lume') => {
|
||||
let colors;
|
||||
switch (themeVariant) {
|
||||
case 'nature':
|
||||
colors = colorScheme === 'dark' ? natureDarkColors : natureLightColors;
|
||||
break;
|
||||
case 'stone':
|
||||
colors = colorScheme === 'dark' ? stoneDarkColors : stoneLightColors;
|
||||
break;
|
||||
default:
|
||||
colors = colorScheme === 'dark' ? darkColors : lightColors;
|
||||
}
|
||||
|
||||
return {
|
||||
colors,
|
||||
tagColors,
|
||||
fontSizes,
|
||||
fontWeights,
|
||||
spacing,
|
||||
borderRadius,
|
||||
shadows,
|
||||
zIndex,
|
||||
};
|
||||
};
|
||||
|
||||
// Typdefinition für das Theme
|
||||
export type Theme = ReturnType<typeof getTheme>;
|
||||
|
||||
// Am Ende der Datei fügen Sie diese Zeile hinzu:
|
||||
export const defaultTheme = getTheme('light');
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 13.4.2",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
// @ts-check
|
||||
import { baseConfig, typescriptConfig, reactConfig, prettierConfig } from '@manacore/eslint-config';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', '.expo/**', 'node_modules/**', 'android/**', 'ios/**'],
|
||||
},
|
||||
...baseConfig,
|
||||
...typescriptConfig,
|
||||
...reactConfig,
|
||||
...prettierConfig,
|
||||
];
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
const { getDefaultConfig } = require('@expo/metro-config');
|
||||
|
||||
const defaultConfig = getDefaultConfig(__dirname);
|
||||
defaultConfig.resolver.sourceExts.push('cjs');
|
||||
|
||||
module.exports = defaultConfig;
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
{
|
||||
"name": "@presi/mobile",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "eslint .",
|
||||
"format": "eslint . --fix",
|
||||
"type-check": "echo 'Skipping type-check: @presi/mobile needs Firebase migration'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@presi/shared": "workspace:*",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"expo": "~52.0.11",
|
||||
"expo-blur": "~14.0.1",
|
||||
"expo-constants": "~17.0.3",
|
||||
"expo-font": "~13.0.1",
|
||||
"expo-haptics": "~14.0.0",
|
||||
"expo-image-picker": "~16.0.3",
|
||||
"expo-linking": "~7.0.3",
|
||||
"expo-router": "~4.0.9",
|
||||
"expo-screen-orientation": "~8.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-splash-screen": "~0.29.13",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-symbols": "~0.2.0",
|
||||
"expo-system-ui": "~4.0.4",
|
||||
"expo-web-browser": "~14.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.3",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.12.2",
|
||||
"zeego": "^2.0.4",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~18.3.12",
|
||||
"eslint": "^9.39.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import {
|
||||
signInWithEmailAndPassword,
|
||||
createUserWithEmailAndPassword,
|
||||
signOut,
|
||||
sendPasswordResetEmail,
|
||||
onAuthStateChanged,
|
||||
type User,
|
||||
} from 'firebase/auth';
|
||||
import { auth } from '../firebaseConfig';
|
||||
import { doc, setDoc } from 'firebase/firestore';
|
||||
import { db } from '../firebaseConfig';
|
||||
|
||||
export const loginUser = async (email: string, password: string): Promise<User> => {
|
||||
const userCredential = await signInWithEmailAndPassword(auth, email, password);
|
||||
return userCredential.user;
|
||||
};
|
||||
|
||||
export const registerUser = async (email: string, password: string): Promise<User> => {
|
||||
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
|
||||
|
||||
// Create user document in Firestore
|
||||
await setDoc(doc(db, 'users', userCredential.user.uid), {
|
||||
email: userCredential.user.email,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return userCredential.user;
|
||||
};
|
||||
|
||||
export const logoutUser = async (): Promise<void> => {
|
||||
await signOut(auth);
|
||||
};
|
||||
|
||||
export const resetPassword = async (email: string): Promise<void> => {
|
||||
await sendPasswordResetEmail(auth, email);
|
||||
};
|
||||
|
||||
export const getCurrentUser = (): User | null => {
|
||||
return auth.currentUser;
|
||||
};
|
||||
|
||||
export const onAuthStateChange = (callback: (user: User | null) => void): (() => void) => {
|
||||
return onAuthStateChanged(auth, callback);
|
||||
};
|
||||
|
||||
export const isAuthenticated = (): boolean => {
|
||||
return auth.currentUser !== null;
|
||||
};
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
import {
|
||||
collection,
|
||||
query,
|
||||
where,
|
||||
getDocs,
|
||||
addDoc,
|
||||
updateDoc,
|
||||
deleteDoc,
|
||||
doc,
|
||||
getDoc,
|
||||
orderBy,
|
||||
limit,
|
||||
writeBatch,
|
||||
} from 'firebase/firestore';
|
||||
import { db, auth } from '../firebaseConfig';
|
||||
import { Deck } from '../types/models';
|
||||
import type { Slide } from '../types/models';
|
||||
|
||||
// Decks
|
||||
export const getUserDecks = async (userId: string): Promise<Deck[]> => {
|
||||
console.log('[Firestore] Getting all decks for user:', userId);
|
||||
|
||||
try {
|
||||
const decksRef = collection(db, 'decks');
|
||||
const q = query(decksRef, where('userId', '==', userId), orderBy('createdAt', 'desc'));
|
||||
|
||||
const querySnapshot = await getDocs(q);
|
||||
const decks = querySnapshot.docs.map((doc) => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
...data,
|
||||
createdAt: data.createdAt?.toDate() || new Date(),
|
||||
updatedAt: data.updatedAt?.toDate() || new Date(),
|
||||
} as Deck;
|
||||
});
|
||||
|
||||
console.log('[Firestore] Retrieved decks:', decks.length);
|
||||
return decks;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting decks:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createDeck = async (deckData: Partial<Deck>): Promise<Deck> => {
|
||||
console.log('[Firestore] Creating new deck:', deckData);
|
||||
|
||||
try {
|
||||
if (!auth.currentUser) {
|
||||
throw new Error('No authenticated user');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const newDeck = {
|
||||
...deckData,
|
||||
userId: auth.currentUser.uid,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sharing: {
|
||||
isPublic: false,
|
||||
collaborators: {},
|
||||
},
|
||||
};
|
||||
|
||||
const docRef = await addDoc(collection(db, 'decks'), newDeck);
|
||||
|
||||
return {
|
||||
id: docRef.id,
|
||||
...newDeck,
|
||||
} as Deck;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error creating deck:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDeck = async (deckId: string): Promise<Deck> => {
|
||||
console.log('[Firestore] Getting deck:', deckId);
|
||||
|
||||
try {
|
||||
const deckRef = doc(db, 'decks', deckId);
|
||||
const deckDoc = await getDoc(deckRef);
|
||||
|
||||
if (!deckDoc.exists()) {
|
||||
throw new Error('Deck not found');
|
||||
}
|
||||
|
||||
const data = deckDoc.data();
|
||||
return {
|
||||
id: deckDoc.id,
|
||||
...data,
|
||||
createdAt: data.createdAt?.toDate() || new Date(),
|
||||
updatedAt: data.updatedAt?.toDate() || new Date(),
|
||||
} as Deck;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting deck:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDeck = async (deckId: string): Promise<void> => {
|
||||
try {
|
||||
console.log('[Firestore] Deleting deck:', deckId);
|
||||
const deckRef = doc(db, 'decks', deckId);
|
||||
await deleteDoc(deckRef);
|
||||
console.log('[Firestore] Deck deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error deleting deck:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Slides
|
||||
export const getDeckSlides = async (deckId: string): Promise<Slide[]> => {
|
||||
console.log('[Firestore] Getting slides for deck:', deckId);
|
||||
|
||||
try {
|
||||
const slidesRef = collection(db, 'decks', deckId, 'slides');
|
||||
const q = query(slidesRef, orderBy('order', 'asc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
|
||||
const slides = querySnapshot.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
createdAt: doc.data().createdAt?.toDate() || new Date(),
|
||||
updatedAt: doc.data().updatedAt?.toDate() || new Date(),
|
||||
})) as Slide[];
|
||||
|
||||
console.log('[Firestore] Retrieved slides:', slides.length);
|
||||
return slides;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error getting slides:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createSlide = async (slideData: Partial<Slide>): Promise<Slide> => {
|
||||
console.log('[Firestore] Creating new slide:', slideData);
|
||||
|
||||
if (!slideData.deckId) {
|
||||
throw new Error('deckId is required to create a slide');
|
||||
}
|
||||
|
||||
try {
|
||||
const slidesRef = collection(db, 'decks', slideData.deckId, 'slides');
|
||||
const now = new Date();
|
||||
|
||||
// Get the current highest order
|
||||
const q = query(slidesRef, orderBy('order', 'desc'), limit(1));
|
||||
const querySnapshot = await getDocs(q);
|
||||
const highestOrder = querySnapshot.empty ? 0 : querySnapshot.docs[0].data().order;
|
||||
|
||||
const newSlide = {
|
||||
...slideData,
|
||||
order: highestOrder + 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const docRef = await addDoc(slidesRef, newSlide);
|
||||
return {
|
||||
id: docRef.id,
|
||||
...newSlide,
|
||||
} as Slide;
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error creating slide:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSlide = async (slideId: string, slideData: Partial<Slide>): Promise<void> => {
|
||||
console.log('[Firestore] Updating slide:', slideId, slideData);
|
||||
|
||||
if (!slideData.deckId) {
|
||||
throw new Error('deckId is required to update a slide');
|
||||
}
|
||||
|
||||
try {
|
||||
const slideRef = doc(db, 'decks', slideData.deckId, 'slides', slideId);
|
||||
await updateDoc(slideRef, {
|
||||
...slideData,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error updating slide:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSlide = async (slideId: string, deckId: string): Promise<void> => {
|
||||
console.log('[Firestore] Deleting slide:', slideId);
|
||||
|
||||
try {
|
||||
const slideRef = doc(db, 'decks', deckId, 'slides', slideId);
|
||||
await deleteDoc(slideRef);
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error deleting slide:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const reorderSlide = async (
|
||||
slideId: string,
|
||||
newOrder: number,
|
||||
deckId: string
|
||||
): Promise<void> => {
|
||||
console.log('[Firestore] Reordering slide:', slideId, 'to order:', newOrder);
|
||||
|
||||
try {
|
||||
const slidesRef = collection(db, 'decks', deckId, 'slides');
|
||||
const batch = writeBatch(db);
|
||||
const now = new Date();
|
||||
|
||||
// Get all slides in the deck
|
||||
const q = query(slidesRef, orderBy('order', 'asc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
const slides = querySnapshot.docs;
|
||||
|
||||
// Find the current slide and its order
|
||||
const currentSlide = slides.find((doc) => doc.id === slideId);
|
||||
if (!currentSlide) {
|
||||
throw new Error('Slide not found');
|
||||
}
|
||||
const currentOrder = currentSlide.data().order;
|
||||
|
||||
// Update orders
|
||||
slides.forEach((doc) => {
|
||||
const slideOrder = doc.data().order;
|
||||
if (doc.id === slideId) {
|
||||
// Update the target slide
|
||||
batch.update(doc.ref, {
|
||||
order: newOrder,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (newOrder > currentOrder && slideOrder > currentOrder && slideOrder <= newOrder) {
|
||||
// Move slides up
|
||||
batch.update(doc.ref, {
|
||||
order: slideOrder - 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
} else if (newOrder < currentOrder && slideOrder >= newOrder && slideOrder < currentOrder) {
|
||||
// Move slides down
|
||||
batch.update(doc.ref, {
|
||||
order: slideOrder + 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await batch.commit();
|
||||
console.log('[Firestore] Reorder operation completed successfully');
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error reordering slide:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const migrateDecksToNewSchema = async (userId: string) => {
|
||||
console.log('[Firestore] Migrating decks to new schema for user:', userId);
|
||||
|
||||
try {
|
||||
const decksRef = collection(db, 'decks');
|
||||
const q = query(decksRef, where('userId', '==', userId));
|
||||
|
||||
const querySnapshot = await getDocs(q);
|
||||
const batch = writeBatch(db);
|
||||
let updateCount = 0;
|
||||
|
||||
querySnapshot.docs.forEach((docSnapshot) => {
|
||||
const deckData = docSnapshot.data();
|
||||
if (!deckData.sharing) {
|
||||
batch.update(docSnapshot.ref, {
|
||||
sharing: {
|
||||
isPublic: false,
|
||||
collaborators: {},
|
||||
},
|
||||
});
|
||||
updateCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (updateCount > 0) {
|
||||
await batch.commit();
|
||||
console.log(`[Firestore] Successfully migrated ${updateCount} decks`);
|
||||
} else {
|
||||
console.log('[Firestore] No decks needed migration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Firestore] Error migrating decks:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { storage } from '../firebaseConfig';
|
||||
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
||||
|
||||
export const uploadImage = async (file: Blob, path: string): Promise<string> => {
|
||||
try {
|
||||
const storageRef = ref(storage, path);
|
||||
const metadata = {
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: 'public,max-age=3600',
|
||||
};
|
||||
const snapshot = await uploadBytes(storageRef, file, metadata);
|
||||
const downloadURL = await getDownloadURL(snapshot.ref);
|
||||
return downloadURL;
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error uploading image:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadImages = async (files: Blob[], basePath: string): Promise<string[]> => {
|
||||
try {
|
||||
const uploadPromises = files.map((file, index) => {
|
||||
const path = `${basePath}/${index}_${Date.now()}.jpg`;
|
||||
return uploadImage(file, path);
|
||||
});
|
||||
return await Promise.all(uploadPromises);
|
||||
} catch (error) {
|
||||
console.error('[Storage] Error uploading images:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
# Portable Theme Module
|
||||
|
||||
Dieses Modul enthält ein komplettes Theme-System für React Native Apps mit:
|
||||
|
||||
- Hell/Dunkel Modus (inkl. System-Einstellung)
|
||||
- Kontrast-Einstellungen (5 Stufen)
|
||||
- Theme Provider & Hooks
|
||||
- Fertige UI-Komponenten für Theme-Einstellungen
|
||||
|
||||
## Installation
|
||||
|
||||
1. Kopiere den gesamten `theme` Ordner in dein Projekt
|
||||
2. Installiere die benötigten Dependencies:
|
||||
|
||||
```bash
|
||||
npm install @react-native-async-storage/async-storage
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
1. Wrapp deine App mit dem ThemeProvider:
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider } from './theme';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<YourApp />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
2. Nutze den useTheme Hook in deinen Komponenten:
|
||||
|
||||
```tsx
|
||||
import { useTheme } from './theme';
|
||||
|
||||
export function MyComponent() {
|
||||
const { theme, isDark } = useTheme();
|
||||
return (
|
||||
<View style={{ backgroundColor: theme.colors.background }}>
|
||||
<Text style={{ color: theme.colors.text }}>Hello World</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
3. Füge die ThemeSettings Komponente in deine Settings-Seite ein:
|
||||
|
||||
```tsx
|
||||
import { ThemeSettings } from './theme';
|
||||
|
||||
export function SettingsScreen() {
|
||||
return (
|
||||
<View>
|
||||
<ThemeSettings />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { getTheme } from './constants';
|
||||
import type { Theme } from './constants';
|
||||
|
||||
export type ColorMode = 'system' | 'light' | 'dark';
|
||||
export type ContrastLevel = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
COLOR_MODE: '@theme/colorMode',
|
||||
CONTRAST_LEVEL: '@theme/contrastLevel',
|
||||
};
|
||||
|
||||
type ThemeContextType = {
|
||||
theme: Theme;
|
||||
isDark: boolean;
|
||||
colorMode: ColorMode;
|
||||
setColorMode: (mode: ColorMode) => void;
|
||||
contrastLevel: ContrastLevel;
|
||||
setContrastLevel: (level: ContrastLevel) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({
|
||||
theme: getTheme('light'),
|
||||
isDark: false,
|
||||
colorMode: 'system',
|
||||
setColorMode: () => {},
|
||||
contrastLevel: 3,
|
||||
setContrastLevel: () => {},
|
||||
});
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
|
||||
// Hilfsfunktion zum Konvertieren von Hex zu RGB
|
||||
const hexToRgb = (hex: string) => {
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.substr(0, 2), 16),
|
||||
g: parseInt(h.substr(2, 2), 16),
|
||||
b: parseInt(h.substr(4, 2), 16),
|
||||
};
|
||||
};
|
||||
|
||||
// Hilfsfunktion zum Konvertieren von RGB zu Hex mit Alpha
|
||||
const rgbaToHex = (r: number, g: number, b: number, a = 1) => {
|
||||
const alpha = Math.round(a * 255);
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b, alpha]
|
||||
.map((x) => {
|
||||
const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
};
|
||||
|
||||
// Funktion zum Anpassen des Kontrasts
|
||||
const adjustContrast = (
|
||||
color: string,
|
||||
level: ContrastLevel,
|
||||
type: 'text' | 'primary' | 'background',
|
||||
isDark: boolean
|
||||
): string => {
|
||||
if (level === 3) return color;
|
||||
|
||||
const { r, g, b } = hexToRgb(color);
|
||||
|
||||
if (level < 3) {
|
||||
// Niedrigerer Kontrast: Nur Text-Opacity wird reduziert
|
||||
if (type === 'text') {
|
||||
const opacity = 0.5 + (level - 1) * 0.25; // 0.5 für Level 1, 0.75 für Level 2
|
||||
return rgbaToHex(r, g, b, opacity);
|
||||
}
|
||||
return color;
|
||||
} else {
|
||||
// Höherer Kontrast: Nur Hintergründe werden angepasst
|
||||
if (type === 'background') {
|
||||
const factor = (level - 3) * 0.45; // 0.45 für Level 4, 0.9 für Level 5
|
||||
if (isDark) {
|
||||
// Im Dark Mode: Hintergründe werden schwärzer
|
||||
return rgbaToHex(
|
||||
Math.round(r * (1 - factor)),
|
||||
Math.round(g * (1 - factor)),
|
||||
Math.round(b * (1 - factor))
|
||||
);
|
||||
} else {
|
||||
// Im Light Mode: Hintergründe werden weißer
|
||||
return rgbaToHex(
|
||||
Math.round(r + (255 - r) * factor),
|
||||
Math.round(g + (255 - g) * factor),
|
||||
Math.round(b + (255 - b) * factor)
|
||||
);
|
||||
}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Anpassen des gesamten Themes basierend auf dem Kontrast-Level
|
||||
const adjustThemeContrast = (theme: Theme, level: ContrastLevel, isDark: boolean): Theme => {
|
||||
return {
|
||||
...theme,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
textPrimary: adjustContrast(theme.colors.textPrimary, level, 'text', isDark),
|
||||
textSecondary: adjustContrast(theme.colors.textSecondary, level, 'text', isDark),
|
||||
backgroundPage: adjustContrast(theme.colors.backgroundPage, level, 'background', isDark),
|
||||
backgroundPrimary: adjustContrast(
|
||||
theme.colors.backgroundPrimary,
|
||||
level,
|
||||
'background',
|
||||
isDark
|
||||
),
|
||||
backgroundSecondary: adjustContrast(
|
||||
theme.colors.backgroundSecondary,
|
||||
level,
|
||||
'background',
|
||||
isDark
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [colorMode, setColorMode] = useState<ColorMode>('system');
|
||||
const [contrastLevel, setContrastLevel] = useState<ContrastLevel>(3);
|
||||
|
||||
// Lade gespeicherte Einstellungen
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const savedColorMode = await AsyncStorage.getItem(STORAGE_KEYS.COLOR_MODE);
|
||||
if (savedColorMode) {
|
||||
setColorMode(savedColorMode as ColorMode);
|
||||
}
|
||||
|
||||
const savedContrastLevel = await AsyncStorage.getItem(STORAGE_KEYS.CONTRAST_LEVEL);
|
||||
if (savedContrastLevel) {
|
||||
setContrastLevel(parseInt(savedContrastLevel) as ContrastLevel);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading theme settings:', error);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Speichere Einstellungen bei Änderungen
|
||||
const handleColorModeChange = async (mode: ColorMode) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.COLOR_MODE, mode);
|
||||
setColorMode(mode);
|
||||
} catch (error) {
|
||||
console.error('Error saving color mode:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContrastLevelChange = async (level: ContrastLevel) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.CONTRAST_LEVEL, level.toString());
|
||||
setContrastLevel(level);
|
||||
} catch (error) {
|
||||
console.error('Error saving contrast level:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Bestimme den aktiven Modus
|
||||
const isDark = colorMode === 'system' ? systemColorScheme === 'dark' : colorMode === 'dark';
|
||||
|
||||
// Hole das Basis-Theme und passe den Kontrast an
|
||||
const baseTheme = getTheme(isDark ? 'dark' : 'light');
|
||||
const theme = adjustThemeContrast(baseTheme, contrastLevel, isDark);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
isDark,
|
||||
colorMode,
|
||||
setColorMode: handleColorModeChange,
|
||||
contrastLevel,
|
||||
setContrastLevel: handleContrastLevelChange,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Pressable } from 'react-native';
|
||||
import { useTheme } from './ThemeProvider';
|
||||
import type { ColorMode, ContrastLevel } from './ThemeProvider';
|
||||
|
||||
const COLOR_MODES: { label: string; value: ColorMode }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Hell', value: 'light' },
|
||||
{ label: 'Dunkel', value: 'dark' },
|
||||
];
|
||||
|
||||
const CONTRAST_LABELS: Record<ContrastLevel, string> = {
|
||||
1: 'Sehr niedrig',
|
||||
2: 'Niedrig',
|
||||
3: 'Standard',
|
||||
4: 'Hoch',
|
||||
5: 'Sehr hoch',
|
||||
};
|
||||
|
||||
export const ThemeSettings = () => {
|
||||
const { theme, colorMode, setColorMode, contrastLevel, setContrastLevel } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Helligkeits-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Helligkeit:</Text>
|
||||
<View style={styles.colorModeList}>
|
||||
{COLOR_MODES.map((mode) => (
|
||||
<TouchableOpacity
|
||||
key={mode.value}
|
||||
style={[
|
||||
styles.colorModeOption,
|
||||
{
|
||||
backgroundColor:
|
||||
mode.value === colorMode
|
||||
? `${theme.colors.primary}1A`
|
||||
: theme.colors.backgroundSecondary,
|
||||
borderColor: mode.value === colorMode ? theme.colors.primary : 'transparent',
|
||||
borderWidth: mode.value === colorMode ? 2 : 0,
|
||||
},
|
||||
]}
|
||||
onPress={() => setColorMode(mode.value)}
|
||||
>
|
||||
<Text style={[styles.colorModeText, { color: theme.colors.textPrimary }]}>
|
||||
{mode.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Kontrast-Einstellungen */}
|
||||
<View style={[styles.section, { backgroundColor: theme.colors.backgroundPrimary }]}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>Kontrast:</Text>
|
||||
<View style={styles.contrastContainer}>
|
||||
<View style={styles.contrastSlider}>
|
||||
{([1, 2, 3, 4, 5] as ContrastLevel[]).map((level) => (
|
||||
<Pressable
|
||||
key={level}
|
||||
style={[
|
||||
styles.contrastOption,
|
||||
{
|
||||
backgroundColor:
|
||||
level === contrastLevel
|
||||
? theme.colors.primary
|
||||
: theme.colors.backgroundSecondary,
|
||||
},
|
||||
]}
|
||||
onPress={() => setContrastLevel(level)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.contrastLabel, { color: theme.colors.textPrimary }]}>
|
||||
{CONTRAST_LABELS[contrastLevel]}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
gap: 16,
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
colorModeList: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
colorModeOption: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
colorModeText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
contrastContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
contrastSlider: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contrastOption: {
|
||||
flex: 1,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
contrastLabel: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
export type ThemeVariant = 'default' | 'modern' | 'classic' | 'dark' | 'light';
|
||||
|
||||
export interface Theme {
|
||||
colors: {
|
||||
primary: string;
|
||||
backgroundPage: string;
|
||||
backgroundPrimary: string;
|
||||
backgroundSecondary: string;
|
||||
textPrimary: string;
|
||||
textSecondary: string;
|
||||
error: string;
|
||||
success: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const THEME_NAMES: Record<ThemeVariant, string> = {
|
||||
default: 'Standard',
|
||||
modern: 'Modern',
|
||||
classic: 'Klassisch',
|
||||
dark: 'Dunkel',
|
||||
light: 'Hell',
|
||||
};
|
||||
|
||||
const LIGHT_THEME: Theme = {
|
||||
colors: {
|
||||
primary: '#007AFF',
|
||||
backgroundPage: '#F2F2F7',
|
||||
backgroundPrimary: '#FFFFFF',
|
||||
backgroundSecondary: '#F2F2F7',
|
||||
textPrimary: '#000000',
|
||||
textSecondary: '#6C6C6C',
|
||||
error: '#FF3B30',
|
||||
success: '#34C759',
|
||||
},
|
||||
};
|
||||
|
||||
const DARK_THEME: Theme = {
|
||||
colors: {
|
||||
primary: '#0A84FF',
|
||||
backgroundPage: '#000000',
|
||||
backgroundPrimary: '#1C1C1E',
|
||||
backgroundSecondary: '#2C2C2E',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#8E8E93',
|
||||
error: '#FF453A',
|
||||
success: '#32D74B',
|
||||
},
|
||||
};
|
||||
|
||||
export function getTheme(mode: 'light' | 'dark'): Theme {
|
||||
return mode === 'light' ? LIGHT_THEME : DARK_THEME;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './constants';
|
||||
export * from './ThemeProvider';
|
||||
export * from './ThemeSettings';
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
export interface Deck {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userId: string;
|
||||
sharing: DeckSharing;
|
||||
}
|
||||
|
||||
export type CollaboratorRole = 'viewer' | 'editor';
|
||||
|
||||
export interface DeckSharing {
|
||||
isPublic: boolean;
|
||||
collaborators: {
|
||||
[userId: string]: CollaboratorRole;
|
||||
};
|
||||
shareLink?: string;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface Slide {
|
||||
id: string;
|
||||
deckId: string;
|
||||
order: number;
|
||||
imageUrl?: string;
|
||||
title: string;
|
||||
fullText?: string;
|
||||
summary?: string;
|
||||
bulletPoints?: string[];
|
||||
notes?: string;
|
||||
altText?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
|
@ -196,7 +196,6 @@
|
|||
"presi:dev": "turbo run dev --filter=presi...",
|
||||
"dev:presi:web": "pnpm --filter @presi/web dev",
|
||||
"dev:presi:landing": "pnpm --filter @presi/landing dev",
|
||||
"dev:presi:mobile": "pnpm --filter @presi/mobile dev",
|
||||
"dev:presi:backend": "pnpm --filter @presi/backend dev",
|
||||
"dev:presi:app": "turbo run dev --filter=@presi/web --filter=@presi/backend",
|
||||
"dev:presi:full": "./scripts/setup-databases.sh presi && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:presi:backend\" \"pnpm dev:presi:web\"",
|
||||
|
|
@ -225,6 +224,7 @@
|
|||
"dev:traces:full": "./scripts/setup-databases.sh traces && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,mobile -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:traces:backend\" \"pnpm dev:traces:mobile\"",
|
||||
"traces:db:push": "pnpm --filter @traces/backend db:push",
|
||||
"traces:db:studio": "pnpm --filter @traces/backend db:studio",
|
||||
"dev:landing-builder": "pnpm --filter @mana-landing-builder/service start:dev",
|
||||
"docker:up": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init",
|
||||
"docker:up:infra": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init",
|
||||
"docker:up:db": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis",
|
||||
|
|
|
|||
|
|
@ -299,15 +299,6 @@ const APP_CONFIGS = [
|
|||
},
|
||||
},
|
||||
|
||||
// Presi Mobile (Expo)
|
||||
{
|
||||
path: 'apps/presi/apps/mobile/.env',
|
||||
vars: {
|
||||
EXPO_PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.PRESI_BACKEND_PORT || '3008'}`,
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
|
||||
},
|
||||
},
|
||||
|
||||
// Presi Web (SvelteKit)
|
||||
{
|
||||
path: 'apps/presi/apps/web/.env',
|
||||
|
|
|
|||