mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 17:39:40 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -57,18 +57,19 @@ pnpm build:prod # Production build
|
|||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Framework | React Native 0.79.5 + Expo SDK 53 |
|
||||
| Navigation | Expo Router v5 (file-based) |
|
||||
| Styling | NativeWind (Tailwind CSS for RN) |
|
||||
| State | Zustand |
|
||||
| Backend | Supabase (PostgreSQL + Auth) |
|
||||
| Language | TypeScript |
|
||||
| Component | Technology |
|
||||
| ---------- | --------------------------------- |
|
||||
| Framework | React Native 0.79.5 + Expo SDK 53 |
|
||||
| Navigation | Expo Router v5 (file-based) |
|
||||
| Styling | NativeWind (Tailwind CSS for RN) |
|
||||
| State | Zustand |
|
||||
| Backend | Supabase (PostgreSQL + Auth) |
|
||||
| Language | TypeScript |
|
||||
|
||||
## Database Design
|
||||
|
||||
Single `texts` table with JSONB field for flexibility:
|
||||
|
||||
- Stores texts, metadata, tags, and reading progress
|
||||
- Audio files stored locally, paths tracked in DB
|
||||
- Designed for future expansion without migrations
|
||||
|
|
@ -78,6 +79,7 @@ See `apps/mobile/ReadMe/MinimalDatabase.md` for details.
|
|||
## Key Implementation Patterns
|
||||
|
||||
### Navigation (Expo Router)
|
||||
|
||||
```tsx
|
||||
// File-based routing in apps/mobile/app/
|
||||
// (tabs)/ - Tab navigation screens
|
||||
|
|
@ -85,26 +87,31 @@ See `apps/mobile/ReadMe/MinimalDatabase.md` for details.
|
|||
```
|
||||
|
||||
### Styling (NativeWind)
|
||||
|
||||
```tsx
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text className="text-lg font-bold">Hello</Text>
|
||||
<Text className="text-lg font-bold">Hello</Text>
|
||||
</View>
|
||||
```
|
||||
|
||||
### State Management (Zustand)
|
||||
|
||||
```tsx
|
||||
import { useStore } from '~/store/store';
|
||||
const { state, actions } = useStore();
|
||||
```
|
||||
|
||||
### Supabase Client
|
||||
|
||||
```tsx
|
||||
// Client configured in apps/mobile/utils/supabase.ts
|
||||
import { supabase } from '~/utils/supabase';
|
||||
```
|
||||
|
||||
### Path Alias
|
||||
|
||||
Use `~/*` for absolute imports from mobile root:
|
||||
|
||||
```tsx
|
||||
import { Button } from '~/components/Button';
|
||||
```
|
||||
|
|
@ -112,6 +119,7 @@ import { Button } from '~/components/Button';
|
|||
## Environment Variables
|
||||
|
||||
Create `apps/reader/apps/mobile/.env`:
|
||||
|
||||
```bash
|
||||
EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"supabase": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@supabase/mcp-server-supabase@latest",
|
||||
"--read-only",
|
||||
"--project-ref=tiecnhktvovcqsrnunko"
|
||||
],
|
||||
"env": {
|
||||
"SUPABASE_ACCESS_TOKEN": "sbp_2faafc5ad01cb28bd9e9bc00bad8e9629e839c44"
|
||||
}
|
||||
}
|
||||
}
|
||||
"mcpServers": {
|
||||
"supabase": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@supabase/mcp-server-supabase@latest",
|
||||
"--read-only",
|
||||
"--project-ref=tiecnhktvovcqsrnunko"
|
||||
],
|
||||
"env": {
|
||||
"SUPABASE_ACCESS_TOKEN": "sbp_2faafc5ad01cb28bd9e9bc00bad8e9629e839c44"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ I've fixed the "View config not found for component 'ContextMenu'" error by repl
|
|||
2. **Created two alternative solutions**:
|
||||
|
||||
### Option 1: ActionMenu (Currently Active)
|
||||
|
||||
- Located in `/components/ActionMenu.tsx`
|
||||
- Uses native ActionSheetIOS on iOS for a truly native experience
|
||||
- Custom modal implementation for Android that slides up from bottom
|
||||
- Triggered by long press on list items
|
||||
|
||||
### Option 2: ContextMenu (Alternative)
|
||||
|
||||
- Located in `/components/ContextMenu.tsx`
|
||||
- Custom modal-based context menu that appears near the pressed item
|
||||
- Works on both platforms with consistent behavior
|
||||
|
|
@ -23,6 +25,7 @@ I've fixed the "View config not found for component 'ContextMenu'" error by repl
|
|||
## How it works:
|
||||
|
||||
The ActionMenu component wraps your list items and provides a long-press gesture handler. When activated:
|
||||
|
||||
- On iOS: Shows native ActionSheetIOS
|
||||
- On Android: Shows a custom bottom sheet modal
|
||||
|
||||
|
|
@ -30,19 +33,21 @@ The ActionMenu component wraps your list items and provides a long-press gesture
|
|||
|
||||
```tsx
|
||||
<ActionMenu
|
||||
options={[
|
||||
{ title: 'Open', systemIcon: 'doc.text' },
|
||||
{ title: 'Share', systemIcon: 'square.and.arrow.up' },
|
||||
{ title: 'Delete', systemIcon: 'trash', destructive: true },
|
||||
]}
|
||||
onSelect={(index) => {
|
||||
// Handle selection
|
||||
}}>
|
||||
<YourComponent />
|
||||
options={[
|
||||
{ title: 'Open', systemIcon: 'doc.text' },
|
||||
{ title: 'Share', systemIcon: 'square.and.arrow.up' },
|
||||
{ title: 'Delete', systemIcon: 'trash', destructive: true },
|
||||
]}
|
||||
onSelect={(index) => {
|
||||
// Handle selection
|
||||
}}
|
||||
>
|
||||
<YourComponent />
|
||||
</ActionMenu>
|
||||
```
|
||||
|
||||
## Benefits:
|
||||
|
||||
- Works with Expo Go (no development build required)
|
||||
- Native feel on iOS
|
||||
- Consistent experience across platforms
|
||||
|
|
@ -51,8 +56,9 @@ The ActionMenu component wraps your list items and provides a long-press gesture
|
|||
## If you need a development build:
|
||||
|
||||
If you prefer to use the native context menu and are willing to use a development build:
|
||||
|
||||
1. Re-install `react-native-context-menu-view`
|
||||
2. Run `npx expo prebuild`
|
||||
3. Run `npx expo run:ios` or `npx expo run:android`
|
||||
|
||||
The current solution allows you to continue using Expo Go for development while providing a good user experience.
|
||||
The current solution allows you to continue using Expo Go for development while providing a good user experience.
|
||||
|
|
|
|||
|
|
@ -1,47 +1,47 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "reader",
|
||||
"slug": "reader",
|
||||
"version": "1.0.0",
|
||||
"scheme": "reader",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-dev-launcher",
|
||||
{
|
||||
"launchMode": "most-recent"
|
||||
}
|
||||
],
|
||||
"expo-web-browser"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.tilljs.reader"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.tilljs.reader"
|
||||
}
|
||||
}
|
||||
"expo": {
|
||||
"name": "reader",
|
||||
"slug": "reader",
|
||||
"version": "1.0.0",
|
||||
"scheme": "reader",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-dev-launcher",
|
||||
{
|
||||
"launchMode": "most-recent"
|
||||
}
|
||||
],
|
||||
"expo-web-browser"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.tilljs.reader"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.tilljs.reader"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#fff' },
|
||||
}}>
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="register" />
|
||||
<Stack.Screen name="forgot-password" />
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: '#fff' },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="register" />
|
||||
<Stack.Screen name="forgot-password" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,120 +1,122 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Link } from 'expo-router';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
|
||||
export default function ForgotPasswordScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const { resetPassword } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const { resetPassword } = useAuth();
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!email) {
|
||||
setError('Bitte gib deine E-Mail-Adresse ein');
|
||||
return;
|
||||
}
|
||||
const handleResetPassword = async () => {
|
||||
if (!email) {
|
||||
setError('Bitte gib deine E-Mail-Adresse ein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error } = await resetPassword(email);
|
||||
const { error } = await resetPassword(email);
|
||||
|
||||
if (error) {
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setSuccess(true);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (error) {
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setSuccess(true);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<View className="flex-1 justify-center bg-white px-8">
|
||||
<View className="text-center">
|
||||
<Text className="mb-4 text-2xl font-bold text-gray-900">E-Mail gesendet!</Text>
|
||||
<Text className="mb-8 text-gray-600">
|
||||
Wir haben dir einen Link zum Zurücksetzen deines Passworts gesendet. Überprüfe deine
|
||||
E-Mails und folge den Anweisungen.
|
||||
</Text>
|
||||
if (success) {
|
||||
return (
|
||||
<View className="flex-1 justify-center bg-white px-8">
|
||||
<View className="text-center">
|
||||
<Text className="mb-4 text-2xl font-bold text-gray-900">E-Mail gesendet!</Text>
|
||||
<Text className="mb-8 text-gray-600">
|
||||
Wir haben dir einen Link zum Zurücksetzen deines Passworts gesendet. Überprüfe deine
|
||||
E-Mails und folge den Anweisungen.
|
||||
</Text>
|
||||
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<Pressable className="rounded-lg bg-blue-600 px-4 py-3 active:bg-blue-700">
|
||||
<Text className="text-center font-semibold text-white">Zurück zum Login</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<Pressable className="rounded-lg bg-blue-600 px-4 py-3 active:bg-blue-700">
|
||||
<Text className="text-center font-semibold text-white">Zurück zum Login</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1 bg-white">
|
||||
<View className="flex-1 justify-center px-8">
|
||||
<View className="mb-8">
|
||||
<Text className="mb-2 text-4xl font-bold text-gray-900">Passwort zurücksetzen</Text>
|
||||
<Text className="text-gray-600">
|
||||
Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen
|
||||
</Text>
|
||||
</View>
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1 bg-white"
|
||||
>
|
||||
<View className="flex-1 justify-center px-8">
|
||||
<View className="mb-8">
|
||||
<Text className="mb-2 text-4xl font-bold text-gray-900">Passwort zurücksetzen</Text>
|
||||
<Text className="text-gray-600">
|
||||
Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<Text className="text-red-700">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
{error && (
|
||||
<View className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<Text className="text-red-700">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="space-y-4">
|
||||
<View>
|
||||
<Text className="mb-1 text-sm font-medium text-gray-700">E-Mail</Text>
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="deine@email.de"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
className="rounded-lg border border-gray-300 px-4 py-3 text-base"
|
||||
/>
|
||||
</View>
|
||||
<View className="space-y-4">
|
||||
<View>
|
||||
<Text className="mb-1 text-sm font-medium text-gray-700">E-Mail</Text>
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="deine@email.de"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
className="rounded-lg border border-gray-300 px-4 py-3 text-base"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={handleResetPassword}
|
||||
disabled={loading}
|
||||
className={`rounded-lg px-4 py-3 ${
|
||||
loading ? 'bg-gray-400' : 'bg-blue-600 active:bg-blue-700'
|
||||
}`}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-center text-base font-semibold text-white">
|
||||
Reset-Link senden
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleResetPassword}
|
||||
disabled={loading}
|
||||
className={`rounded-lg px-4 py-3 ${
|
||||
loading ? 'bg-gray-400' : 'bg-blue-600 active:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-center text-base font-semibold text-white">
|
||||
Reset-Link senden
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View className="mt-4 flex-row justify-center">
|
||||
<Text className="text-gray-600">Erinnerst du dich wieder? </Text>
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<Pressable>
|
||||
<Text className="font-medium text-blue-600">Anmelden</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<View className="mt-4 flex-row justify-center">
|
||||
<Text className="text-gray-600">Erinnerst du dich wieder? </Text>
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<Pressable>
|
||||
<Text className="font-medium text-blue-600">Anmelden</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,125 +1,127 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Link, router } from 'expo-router';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { signIn } = useAuth();
|
||||
const { colors } = useTheme();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { signIn } = useAuth();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
setError('Bitte fülle alle Felder aus');
|
||||
return;
|
||||
}
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
setError('Bitte fülle alle Felder aus');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError('Bitte gib eine gültige E-Mail-Adresse ein');
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError('Bitte gib eine gültige E-Mail-Adresse ein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error } = await signIn(email, password);
|
||||
const { error } = await signIn(email, password);
|
||||
|
||||
if (error) {
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
} else {
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
};
|
||||
if (error) {
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
} else {
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className={`flex-1 ${colors.surface}`}>
|
||||
<View className="flex-1 justify-center px-8">
|
||||
<View className="mb-8">
|
||||
<Text className={`mb-2 text-4xl font-bold ${colors.text}`}>Willkommen zurück</Text>
|
||||
<Text className={`${colors.textSecondary}`}>Melde dich an, um fortzufahren</Text>
|
||||
</View>
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className={`flex-1 ${colors.surface}`}
|
||||
>
|
||||
<View className="flex-1 justify-center px-8">
|
||||
<View className="mb-8">
|
||||
<Text className={`mb-2 text-4xl font-bold ${colors.text}`}>Willkommen zurück</Text>
|
||||
<Text className={`${colors.textSecondary}`}>Melde dich an, um fortzufahren</Text>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View className={`mb-4 rounded-lg border border-red-200 ${colors.errorLight} p-3`}>
|
||||
<Text className="text-red-700">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
{error && (
|
||||
<View className={`mb-4 rounded-lg border border-red-200 ${colors.errorLight} p-3`}>
|
||||
<Text className="text-red-700">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="space-y-4">
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-1 text-sm font-medium ${colors.textSecondary}`}>E-Mail</Text>
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="deine@email.de"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
textContentType="emailAddress"
|
||||
autoComplete="email"
|
||||
accessibilityLabel="E-Mail eingeben"
|
||||
className={`rounded-lg border ${colors.borderSecondary} px-4 py-3 text-base focus:border-blue-500 ${colors.text}`}
|
||||
/>
|
||||
</View>
|
||||
<View className="space-y-4">
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-1 text-sm font-medium ${colors.textSecondary}`}>E-Mail</Text>
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="deine@email.de"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
textContentType="emailAddress"
|
||||
autoComplete="email"
|
||||
accessibilityLabel="E-Mail eingeben"
|
||||
className={`rounded-lg border ${colors.borderSecondary} px-4 py-3 text-base focus:border-blue-500 ${colors.text}`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-1 text-sm font-medium ${colors.textSecondary}`}>Passwort</Text>
|
||||
<TextInput
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Dein Passwort"
|
||||
secureTextEntry
|
||||
textContentType="none"
|
||||
autoComplete="off"
|
||||
accessibilityLabel="Passwort eingeben"
|
||||
className={`rounded-lg border ${colors.borderSecondary} px-4 py-3 text-base focus:border-blue-500 ${colors.text}`}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-1 text-sm font-medium ${colors.textSecondary}`}>Passwort</Text>
|
||||
<TextInput
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Dein Passwort"
|
||||
secureTextEntry
|
||||
textContentType="none"
|
||||
autoComplete="off"
|
||||
accessibilityLabel="Passwort eingeben"
|
||||
className={`rounded-lg border ${colors.borderSecondary} px-4 py-3 text-base focus:border-blue-500 ${colors.text}`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Anmelden"
|
||||
className={`mt-2 rounded-lg px-4 py-3 ${loading ? 'bg-gray-400' : colors.primary}`}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-center text-base font-semibold text-white">Anmelden</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Anmelden"
|
||||
className={`mt-2 rounded-lg px-4 py-3 ${loading ? 'bg-gray-400' : colors.primary}`}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-center text-base font-semibold text-white">Anmelden</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View className="mt-4 flex-row justify-center">
|
||||
<Text className={`${colors.textSecondary}`}>Noch kein Konto? </Text>
|
||||
<Link href="/(auth)/register" asChild>
|
||||
<Pressable>
|
||||
<Text className="font-medium text-blue-600">Registrieren</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
<View className="mt-4 flex-row justify-center">
|
||||
<Text className={`${colors.textSecondary}`}>Noch kein Konto? </Text>
|
||||
<Link href="/(auth)/register" asChild>
|
||||
<Pressable>
|
||||
<Text className="font-medium text-blue-600">Registrieren</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<Link href="/(auth)/forgot-password" asChild>
|
||||
<Pressable className="mt-2">
|
||||
<Text className={`text-center ${colors.textSecondary}`}>Passwort vergessen?</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<Link href="/(auth)/forgot-password" asChild>
|
||||
<Pressable className="mt-2">
|
||||
<Text className={`text-center ${colors.textSecondary}`}>Passwort vergessen?</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,144 +1,146 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Link, router } from 'expo-router';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { signUp } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { signUp } = useAuth();
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!email || !password || !confirmPassword) {
|
||||
setError('Bitte fülle alle Felder aus');
|
||||
return;
|
||||
}
|
||||
const handleRegister = async () => {
|
||||
if (!email || !password || !confirmPassword) {
|
||||
setError('Bitte fülle alle Felder aus');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError('Bitte gib eine gültige E-Mail-Adresse ein');
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError('Bitte gib eine gültige E-Mail-Adresse ein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error } = await signUp(email, password);
|
||||
const { error } = await signUp(email, password);
|
||||
|
||||
if (error) {
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
} else {
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
};
|
||||
if (error) {
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
} else {
|
||||
router.replace('/(tabs)');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1 bg-white">
|
||||
<View className="flex-1 justify-center px-8">
|
||||
<View className="mb-8">
|
||||
<Text className="mb-2 text-4xl font-bold text-gray-900">Konto erstellen</Text>
|
||||
<Text className="text-gray-600">Registriere dich für Reader</Text>
|
||||
</View>
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1 bg-white"
|
||||
>
|
||||
<View className="flex-1 justify-center px-8">
|
||||
<View className="mb-8">
|
||||
<Text className="mb-2 text-4xl font-bold text-gray-900">Konto erstellen</Text>
|
||||
<Text className="text-gray-600">Registriere dich für Reader</Text>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<Text className="text-red-700">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
{error && (
|
||||
<View className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<Text className="text-red-700">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="space-y-4">
|
||||
<View className="mb-4">
|
||||
<Text className="mb-1 text-sm font-medium text-gray-700">E-Mail</Text>
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="deine@email.de"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
textContentType="emailAddress"
|
||||
autoComplete="email"
|
||||
accessibilityLabel="E-Mail eingeben"
|
||||
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-blue-500"
|
||||
/>
|
||||
</View>
|
||||
<View className="space-y-4">
|
||||
<View className="mb-4">
|
||||
<Text className="mb-1 text-sm font-medium text-gray-700">E-Mail</Text>
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="deine@email.de"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
textContentType="emailAddress"
|
||||
autoComplete="email"
|
||||
accessibilityLabel="E-Mail eingeben"
|
||||
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-blue-500"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="mb-1 text-sm font-medium text-gray-700">Passwort</Text>
|
||||
<TextInput
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
secureTextEntry
|
||||
textContentType="none"
|
||||
autoComplete="off"
|
||||
accessibilityLabel="Passwort eingeben"
|
||||
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-blue-500"
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<Text className="mb-1 text-sm font-medium text-gray-700">Passwort</Text>
|
||||
<TextInput
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
secureTextEntry
|
||||
textContentType="none"
|
||||
autoComplete="off"
|
||||
accessibilityLabel="Passwort eingeben"
|
||||
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-blue-500"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="mb-1 text-sm font-medium text-gray-700">Passwort bestätigen</Text>
|
||||
<TextInput
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
placeholder="Passwort wiederholen"
|
||||
secureTextEntry
|
||||
textContentType="none"
|
||||
autoComplete="off"
|
||||
accessibilityLabel="Passwort bestätigen"
|
||||
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-blue-500"
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<Text className="mb-1 text-sm font-medium text-gray-700">Passwort bestätigen</Text>
|
||||
<TextInput
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
placeholder="Passwort wiederholen"
|
||||
secureTextEntry
|
||||
textContentType="none"
|
||||
autoComplete="off"
|
||||
accessibilityLabel="Passwort bestätigen"
|
||||
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-blue-500"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={handleRegister}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Registrieren"
|
||||
className={`mt-2 rounded-lg px-4 py-3 ${
|
||||
loading ? 'bg-gray-400' : 'bg-blue-600 active:bg-blue-700'
|
||||
}`}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-center text-base font-semibold text-white">Registrieren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRegister}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Registrieren"
|
||||
className={`mt-2 rounded-lg px-4 py-3 ${
|
||||
loading ? 'bg-gray-400' : 'bg-blue-600 active:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-center text-base font-semibold text-white">Registrieren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View className="mt-4 flex-row justify-center">
|
||||
<Text className="text-gray-600">Schon ein Konto? </Text>
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<Pressable>
|
||||
<Text className="font-medium text-blue-600">Anmelden</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<View className="mt-4 flex-row justify-center">
|
||||
<Text className="text-gray-600">Schon ein Konto? </Text>
|
||||
<Link href="/(auth)/login" asChild>
|
||||
<Pressable>
|
||||
<Text className="font-medium text-blue-600">Anmelden</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,32 +3,33 @@ import { TabBarIcon } from '../../components/TabBarIcon';
|
|||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: colors.tabBarActive,
|
||||
tabBarInactiveTintColor: colors.tabBarInactive,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.tabBarBackground,
|
||||
borderTopColor: colors.tabBarBorder,
|
||||
},
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Texte',
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="book" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: 'Einstellungen',
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="cog" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: colors.tabBarActive,
|
||||
tabBarInactiveTintColor: colors.tabBarInactive,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.tabBarBackground,
|
||||
borderTopColor: colors.tabBarBorder,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Texte',
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="book" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: 'Einstellungen',
|
||||
tabBarIcon: ({ color }) => <TabBarIcon name="cog" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Share,
|
||||
AppState,
|
||||
ScrollView,
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Share,
|
||||
AppState,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { Stack, router, useFocusEffect } from 'expo-router';
|
||||
import { useTexts } from '~/hooks/useTexts';
|
||||
|
|
@ -24,315 +24,319 @@ import * as Clipboard from 'expo-clipboard';
|
|||
import { urlExtractorService } from '~/services/urlExtractorService';
|
||||
|
||||
export default function Home() {
|
||||
const { texts, loading, error, refetch, deleteText, createText } = useTexts();
|
||||
const { signOut } = useAuth();
|
||||
const { selectedTags, settings } = useStore();
|
||||
const { colors } = useTheme();
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [clipboardHasUrl, setClipboardHasUrl] = useState(false);
|
||||
const { texts, loading, error, refetch, deleteText, createText } = useTexts();
|
||||
const { signOut } = useAuth();
|
||||
const { selectedTags, settings } = useStore();
|
||||
const { colors } = useTheme();
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [clipboardHasUrl, setClipboardHasUrl] = useState(false);
|
||||
|
||||
// Check clipboard content on mount and when app becomes active
|
||||
useEffect(() => {
|
||||
const checkClipboard = async () => {
|
||||
try {
|
||||
const content = await Clipboard.getStringAsync();
|
||||
const hasUrl = content ? urlExtractorService.validateUrl(content) : false;
|
||||
setClipboardHasUrl(hasUrl);
|
||||
} catch (error) {
|
||||
console.error('Error checking clipboard:', error);
|
||||
setClipboardHasUrl(false);
|
||||
}
|
||||
};
|
||||
// Check clipboard content on mount and when app becomes active
|
||||
useEffect(() => {
|
||||
const checkClipboard = async () => {
|
||||
try {
|
||||
const content = await Clipboard.getStringAsync();
|
||||
const hasUrl = content ? urlExtractorService.validateUrl(content) : false;
|
||||
setClipboardHasUrl(hasUrl);
|
||||
} catch (error) {
|
||||
console.error('Error checking clipboard:', error);
|
||||
setClipboardHasUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check on mount
|
||||
checkClipboard();
|
||||
// Check on mount
|
||||
checkClipboard();
|
||||
|
||||
// Check when app becomes active
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (nextAppState === 'active') {
|
||||
checkClipboard();
|
||||
}
|
||||
});
|
||||
// Check when app becomes active
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (nextAppState === 'active') {
|
||||
checkClipboard();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Refresh texts when screen comes into focus
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
refetch();
|
||||
}, [])
|
||||
);
|
||||
// Refresh texts when screen comes into focus
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
refetch();
|
||||
}, [])
|
||||
);
|
||||
|
||||
// Filter texts based on selected tags
|
||||
const filteredTexts = useMemo(() => {
|
||||
if (selectedTags.length === 0) {
|
||||
return texts;
|
||||
}
|
||||
// Filter texts based on selected tags
|
||||
const filteredTexts = useMemo(() => {
|
||||
if (selectedTags.length === 0) {
|
||||
return texts;
|
||||
}
|
||||
|
||||
return texts.filter((text) => {
|
||||
const textTags = text.data.tags || [];
|
||||
return selectedTags.every((tag) => textTags.includes(tag));
|
||||
});
|
||||
}, [texts, selectedTags]);
|
||||
return texts.filter((text) => {
|
||||
const textTags = text.data.tags || [];
|
||||
return selectedTags.every((tag) => textTags.includes(tag));
|
||||
});
|
||||
}, [texts, selectedTags]);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (totalTime: number) => {
|
||||
const hours = Math.floor(totalTime / 3600);
|
||||
const minutes = Math.floor((totalTime % 3600) / 60);
|
||||
const seconds = Math.floor(totalTime % 60);
|
||||
const formatDuration = (totalTime: number) => {
|
||||
const hours = Math.floor(totalTime / 3600);
|
||||
const minutes = Math.floor((totalTime % 3600) / 60);
|
||||
const seconds = Math.floor(totalTime % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${seconds} Sek`;
|
||||
};
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${seconds} Sek`;
|
||||
};
|
||||
|
||||
const getAudioDuration = (item: TextType) => {
|
||||
// Try to get duration from current audio version
|
||||
if (item.data.audioVersions && item.data.audioVersions.length > 0) {
|
||||
const currentVersionId = item.data.currentAudioVersion;
|
||||
const currentVersion = currentVersionId
|
||||
? item.data.audioVersions.find((v) => v.id === currentVersionId)
|
||||
: item.data.audioVersions[item.data.audioVersions.length - 1];
|
||||
const getAudioDuration = (item: TextType) => {
|
||||
// Try to get duration from current audio version
|
||||
if (item.data.audioVersions && item.data.audioVersions.length > 0) {
|
||||
const currentVersionId = item.data.currentAudioVersion;
|
||||
const currentVersion = currentVersionId
|
||||
? item.data.audioVersions.find((v) => v.id === currentVersionId)
|
||||
: item.data.audioVersions[item.data.audioVersions.length - 1];
|
||||
|
||||
if (currentVersion && currentVersion.chunks) {
|
||||
const totalSeconds = currentVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0);
|
||||
return formatDuration(totalSeconds);
|
||||
}
|
||||
}
|
||||
if (currentVersion && currentVersion.chunks) {
|
||||
const totalSeconds = currentVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0);
|
||||
return formatDuration(totalSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy audio data
|
||||
if (item.data.audio && item.data.audio.chunks) {
|
||||
const totalSeconds = item.data.audio.chunks.reduce((sum, chunk) => sum + chunk.duration, 0);
|
||||
return formatDuration(totalSeconds);
|
||||
}
|
||||
// Fallback to legacy audio data
|
||||
if (item.data.audio && item.data.audio.chunks) {
|
||||
const totalSeconds = item.data.audio.chunks.reduce((sum, chunk) => sum + chunk.duration, 0);
|
||||
return formatDuration(totalSeconds);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleDelete = async (textId: string, title: string) => {
|
||||
Alert.alert('Text löschen', `Möchten Sie "${title}" wirklich löschen?`, [
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const { error } = await deleteText(textId);
|
||||
if (error) {
|
||||
Alert.alert('Fehler', error);
|
||||
} else {
|
||||
// Manually refresh the list after successful deletion
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
const handleDelete = async (textId: string, title: string) => {
|
||||
Alert.alert('Text löschen', `Möchten Sie "${title}" wirklich löschen?`, [
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const { error } = await deleteText(textId);
|
||||
if (error) {
|
||||
Alert.alert('Fehler', error);
|
||||
} else {
|
||||
// Manually refresh the list after successful deletion
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleShare = async (text: TextType) => {
|
||||
try {
|
||||
const message = `${text.title}\n\n${text.content}`;
|
||||
await Share.share({
|
||||
title: text.title,
|
||||
message: message,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
};
|
||||
const handleShare = async (text: TextType) => {
|
||||
try {
|
||||
const message = `${text.title}\n\n${text.content}`;
|
||||
await Share.share({
|
||||
title: text.title,
|
||||
message: message,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sharing:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClipboardUrl = async () => {
|
||||
try {
|
||||
setExtracting(true);
|
||||
const clipboardContent = await Clipboard.getStringAsync();
|
||||
const handleClipboardUrl = async () => {
|
||||
try {
|
||||
setExtracting(true);
|
||||
const clipboardContent = await Clipboard.getStringAsync();
|
||||
|
||||
if (!clipboardContent) {
|
||||
Alert.alert(
|
||||
'Zwischenablage leer',
|
||||
'Bitte kopieren Sie zuerst eine URL in die Zwischenablage.'
|
||||
);
|
||||
setExtracting(false);
|
||||
return;
|
||||
}
|
||||
if (!clipboardContent) {
|
||||
Alert.alert(
|
||||
'Zwischenablage leer',
|
||||
'Bitte kopieren Sie zuerst eine URL in die Zwischenablage.'
|
||||
);
|
||||
setExtracting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a valid URL
|
||||
if (!urlExtractorService.validateUrl(clipboardContent)) {
|
||||
Alert.alert(
|
||||
'Keine gültige URL',
|
||||
'Die Zwischenablage enthält keine gültige URL. Bitte kopieren Sie eine Webadresse und versuchen Sie es erneut.'
|
||||
);
|
||||
setExtracting(false);
|
||||
return;
|
||||
}
|
||||
// Check if it's a valid URL
|
||||
if (!urlExtractorService.validateUrl(clipboardContent)) {
|
||||
Alert.alert(
|
||||
'Keine gültige URL',
|
||||
'Die Zwischenablage enthält keine gültige URL. Bitte kopieren Sie eine Webadresse und versuchen Sie es erneut.'
|
||||
);
|
||||
setExtracting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract content from URL
|
||||
const { data, error: extractError } =
|
||||
await urlExtractorService.extractFromUrl(clipboardContent);
|
||||
// Extract content from URL
|
||||
const { data, error: extractError } =
|
||||
await urlExtractorService.extractFromUrl(clipboardContent);
|
||||
|
||||
if (extractError) {
|
||||
Alert.alert(
|
||||
'Fehler beim Abrufen',
|
||||
`Die Webseite konnte nicht geladen werden: ${extractError.message}`
|
||||
);
|
||||
setExtracting(false);
|
||||
return;
|
||||
}
|
||||
if (extractError) {
|
||||
Alert.alert(
|
||||
'Fehler beim Abrufen',
|
||||
`Die Webseite konnte nicht geladen werden: ${extractError.message}`
|
||||
);
|
||||
setExtracting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// Create the text with extracted content
|
||||
const { data: createdText, error: createError } = await createText(
|
||||
data.title,
|
||||
urlExtractorService.formatExtractedContent(data),
|
||||
{
|
||||
tags: data.tags,
|
||||
source: data.source,
|
||||
tts: { speed: settings.speed || 1.0, voice: settings.voice || 'de-DE-Neural2-A' },
|
||||
}
|
||||
);
|
||||
if (data) {
|
||||
// Create the text with extracted content
|
||||
const { data: createdText, error: createError } = await createText(
|
||||
data.title,
|
||||
urlExtractorService.formatExtractedContent(data),
|
||||
{
|
||||
tags: data.tags,
|
||||
source: data.source,
|
||||
tts: { speed: settings.speed || 1.0, voice: settings.voice || 'de-DE-Neural2-A' },
|
||||
}
|
||||
);
|
||||
|
||||
if (createError) {
|
||||
Alert.alert(
|
||||
'Fehler beim Speichern',
|
||||
`Der Text konnte nicht gespeichert werden: ${createError}`
|
||||
);
|
||||
} else if (createdText) {
|
||||
// Refresh the list before navigating
|
||||
await refetch();
|
||||
// Navigate to the newly created text
|
||||
router.push(`/text/${createdText.id}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing clipboard URL:', error);
|
||||
Alert.alert(
|
||||
'Unerwarteter Fehler',
|
||||
'Beim Verarbeiten der URL ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'
|
||||
);
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
};
|
||||
if (createError) {
|
||||
Alert.alert(
|
||||
'Fehler beim Speichern',
|
||||
`Der Text konnte nicht gespeichert werden: ${createError}`
|
||||
);
|
||||
} else if (createdText) {
|
||||
// Refresh the list before navigating
|
||||
await refetch();
|
||||
// Navigate to the newly created text
|
||||
router.push(`/text/${createdText.id}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing clipboard URL:', error);
|
||||
Alert.alert(
|
||||
'Unerwarteter Fehler',
|
||||
'Beim Verarbeiten der URL ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'
|
||||
);
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTextItem = ({ item }: { item: TextType }) => (
|
||||
<TextListItem
|
||||
item={item}
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
formatDate={formatDate}
|
||||
getAudioDuration={getAudioDuration}
|
||||
/>
|
||||
);
|
||||
const renderTextItem = ({ item }: { item: TextType }) => (
|
||||
<TextListItem
|
||||
item={item}
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
formatDate={formatDate}
|
||||
getAudioDuration={getAudioDuration}
|
||||
/>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Meine Texte" showBackButton={false} />
|
||||
<View className={`flex-1 items-center justify-center ${colors.background}`}>
|
||||
<ActivityIndicator size="large" color="#3B82F6" />
|
||||
<Text className={`mt-2 ${colors.textSecondary}`}>Texte werden geladen...</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Meine Texte" showBackButton={false} />
|
||||
<View className={`flex-1 items-center justify-center ${colors.background}`}>
|
||||
<ActivityIndicator size="large" color="#3B82F6" />
|
||||
<Text className={`mt-2 ${colors.textSecondary}`}>Texte werden geladen...</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Meine Texte" showBackButton={false} />
|
||||
<View className={`flex-1 items-center justify-center px-4 ${colors.background}`}>
|
||||
<Text className="mb-4 text-center text-red-600">{error}</Text>
|
||||
<Pressable onPress={() => refetch()} className={`rounded-lg ${colors.primary} px-4 py-2`}>
|
||||
<Text className="text-white">Erneut versuchen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Meine Texte" showBackButton={false} />
|
||||
<View className={`flex-1 items-center justify-center px-4 ${colors.background}`}>
|
||||
<Text className="mb-4 text-center text-red-600">{error}</Text>
|
||||
<Pressable onPress={() => refetch()} className={`rounded-lg ${colors.primary} px-4 py-2`}>
|
||||
<Text className="text-white">Erneut versuchen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Meine Texte" showBackButton={false} />
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Meine Texte" showBackButton={false} />
|
||||
|
||||
<View className={`flex-1 ${colors.background}`}>
|
||||
<TagFilter />
|
||||
<View className={`flex-1 ${colors.background}`}>
|
||||
<TagFilter />
|
||||
|
||||
{texts.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center px-4">
|
||||
<Text className={`mb-4 text-center ${colors.textTertiary}`}>
|
||||
Noch keine Texte vorhanden
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => router.push('/add-text')}
|
||||
className={`rounded-lg ${colors.primary} px-6 py-3`}>
|
||||
<Text className="font-semibold text-white">Ersten Text hinzufügen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : filteredTexts.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center px-4">
|
||||
<Text className={`mb-4 text-center ${colors.textTertiary}`}>
|
||||
Keine Texte mit den gewählten Tags gefunden
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => router.push('/add-text')}
|
||||
className={`rounded-lg ${colors.primary} px-6 py-3`}>
|
||||
<Text className="font-semibold text-white">Neuen Text hinzufügen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredTexts}
|
||||
renderItem={renderTextItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 100 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
{texts.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center px-4">
|
||||
<Text className={`mb-4 text-center ${colors.textTertiary}`}>
|
||||
Noch keine Texte vorhanden
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => router.push('/add-text')}
|
||||
className={`rounded-lg ${colors.primary} px-6 py-3`}
|
||||
>
|
||||
<Text className="font-semibold text-white">Ersten Text hinzufügen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : filteredTexts.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center px-4">
|
||||
<Text className={`mb-4 text-center ${colors.textTertiary}`}>
|
||||
Keine Texte mit den gewählten Tags gefunden
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => router.push('/add-text')}
|
||||
className={`rounded-lg ${colors.primary} px-6 py-3`}
|
||||
>
|
||||
<Text className="font-semibold text-white">Neuen Text hinzufügen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredTexts}
|
||||
renderItem={renderTextItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 100 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View
|
||||
className={`absolute bottom-0 left-0 right-0 ${colors.surface} border-t ${colors.border} shadow-lg`}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 16 }}
|
||||
className="flex-row">
|
||||
<FloatingActionButton
|
||||
onPress={() => router.push('/add-text')}
|
||||
icon="+"
|
||||
label="Neuer Text"
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<View
|
||||
className={`absolute bottom-0 left-0 right-0 ${colors.surface} border-t ${colors.border} shadow-lg`}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 16 }}
|
||||
className="flex-row"
|
||||
>
|
||||
<FloatingActionButton
|
||||
onPress={() => router.push('/add-text')}
|
||||
icon="+"
|
||||
label="Neuer Text"
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
|
||||
<FloatingActionButton
|
||||
onPress={handleClipboardUrl}
|
||||
icon="📋"
|
||||
label={clipboardHasUrl ? 'URL einfügen' : 'Keine URL'}
|
||||
disabled={!clipboardHasUrl}
|
||||
loading={extracting}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
<FloatingActionButton
|
||||
onPress={handleClipboardUrl}
|
||||
icon="📋"
|
||||
label={clipboardHasUrl ? 'URL einfügen' : 'Keine URL'}
|
||||
disabled={!clipboardHasUrl}
|
||||
loading={extracting}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,204 +8,208 @@ import { useTheme } from '~/hooks/useTheme';
|
|||
import { Header } from '~/components/Header';
|
||||
import { Dropdown } from '~/components/dropdown';
|
||||
import {
|
||||
GERMAN_VOICES,
|
||||
QUALITY_LABELS,
|
||||
PROVIDER_LABELS,
|
||||
getVoiceById,
|
||||
LEGACY_VOICE_MAP,
|
||||
GERMAN_VOICES,
|
||||
QUALITY_LABELS,
|
||||
PROVIDER_LABELS,
|
||||
getVoiceById,
|
||||
LEGACY_VOICE_MAP,
|
||||
} from '~/constants/voices';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { settings, updateSettings } = useStore();
|
||||
const { user, signOut } = useAuth();
|
||||
const { texts, getAllTags } = useTexts();
|
||||
const { colors } = useTheme();
|
||||
const { settings, updateSettings } = useStore();
|
||||
const { user, signOut } = useAuth();
|
||||
const { texts, getAllTags } = useTexts();
|
||||
const { colors } = useTheme();
|
||||
|
||||
// Map legacy voice settings to new voice IDs
|
||||
const currentVoice = LEGACY_VOICE_MAP[settings.voice] || settings.voice || 'de-DE-Neural2-A';
|
||||
// Map legacy voice settings to new voice IDs
|
||||
const currentVoice = LEGACY_VOICE_MAP[settings.voice] || settings.voice || 'de-DE-Neural2-A';
|
||||
|
||||
const speeds = [
|
||||
{ value: 0.5, label: 'Langsam (0.5x)' },
|
||||
{ value: 0.75, label: 'Etwas langsam (0.75x)' },
|
||||
{ value: 1.0, label: 'Normal (1.0x)' },
|
||||
{ value: 1.25, label: 'Etwas schnell (1.25x)' },
|
||||
{ value: 1.5, label: 'Schnell (1.5x)' },
|
||||
{ value: 2.0, label: 'Sehr schnell (2.0x)' },
|
||||
];
|
||||
const speeds = [
|
||||
{ value: 0.5, label: 'Langsam (0.5x)' },
|
||||
{ value: 0.75, label: 'Etwas langsam (0.75x)' },
|
||||
{ value: 1.0, label: 'Normal (1.0x)' },
|
||||
{ value: 1.25, label: 'Etwas schnell (1.25x)' },
|
||||
{ value: 1.5, label: 'Schnell (1.5x)' },
|
||||
{ value: 2.0, label: 'Sehr schnell (2.0x)' },
|
||||
];
|
||||
|
||||
const themes = [
|
||||
{ value: 'light', label: 'Hell' },
|
||||
{ value: 'dark', label: 'Dunkel' },
|
||||
];
|
||||
const themes = [
|
||||
{ value: 'light', label: 'Hell' },
|
||||
{ value: 'dark', label: 'Dunkel' },
|
||||
];
|
||||
|
||||
const totalTexts = texts.length;
|
||||
const totalTags = getAllTags().length;
|
||||
const textsWithAudio = texts.filter((t) => t.data.audio?.hasLocalCache).length;
|
||||
const totalAudioSize = texts.reduce((sum, text) => {
|
||||
return sum + (text.data.audio?.totalSize || 0);
|
||||
}, 0);
|
||||
const totalTexts = texts.length;
|
||||
const totalTags = getAllTags().length;
|
||||
const textsWithAudio = texts.filter((t) => t.data.audio?.hasLocalCache).length;
|
||||
const totalAudioSize = texts.reduce((sum, text) => {
|
||||
return sum + (text.data.audio?.totalSize || 0);
|
||||
}, 0);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
router.replace('/(auth)/login');
|
||||
};
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
router.replace('/(auth)/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Einstellungen" showBackButton={false} />
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Einstellungen" showBackButton={false} />
|
||||
|
||||
<ScrollView className={`flex-1 ${colors.background}`}>
|
||||
<View className="p-4">
|
||||
{/* Statistics */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>Statistiken</Text>
|
||||
<ScrollView className={`flex-1 ${colors.background}`}>
|
||||
<View className="p-4">
|
||||
{/* Statistics */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>Statistiken</Text>
|
||||
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Texte gesamt:</Text>
|
||||
<Text className={`${colors.text}`}>{totalTexts}</Text>
|
||||
</View>
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Texte gesamt:</Text>
|
||||
<Text className={`${colors.text}`}>{totalTexts}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Tags:</Text>
|
||||
<Text className={`${colors.text}`}>{totalTags}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Tags:</Text>
|
||||
<Text className={`${colors.text}`}>{totalTags}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Texte mit Audio:</Text>
|
||||
<Text className={`${colors.text}`}>{textsWithAudio}</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Texte mit Audio:</Text>
|
||||
<Text className={`${colors.text}`}>{textsWithAudio}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Audio-Speicher:</Text>
|
||||
<Text className={`${colors.text}`}>
|
||||
{(totalAudioSize / 1024 / 1024).toFixed(2)} MB
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Audio-Speicher:</Text>
|
||||
<Text className={`${colors.text}`}>
|
||||
{(totalAudioSize / 1024 / 1024).toFixed(2)} MB
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Audio Settings */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>Audio-Einstellungen</Text>
|
||||
{/* Audio Settings */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>Audio-Einstellungen</Text>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Stimme</Text>
|
||||
<Dropdown
|
||||
value={currentVoice}
|
||||
onValueChange={(newVoice) => updateSettings({ voice: newVoice })}
|
||||
placeholder="Stimme wählen"
|
||||
title="Stimme auswählen"
|
||||
groups={Object.entries(
|
||||
GERMAN_VOICES.reduce(
|
||||
(groups, voice) => {
|
||||
const provider = voice.provider;
|
||||
if (!groups[provider]) {
|
||||
groups[provider] = {};
|
||||
}
|
||||
const quality = voice.quality;
|
||||
if (!groups[provider][quality]) {
|
||||
groups[provider][quality] = [];
|
||||
}
|
||||
groups[provider][quality].push(voice);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
|
||||
)
|
||||
).map(([provider, qualityGroups]) => ({
|
||||
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
|
||||
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
|
||||
voices.map((voice) => ({
|
||||
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
|
||||
value: voice.value,
|
||||
}))
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Stimme</Text>
|
||||
<Dropdown
|
||||
value={currentVoice}
|
||||
onValueChange={(newVoice) => updateSettings({ voice: newVoice })}
|
||||
placeholder="Stimme wählen"
|
||||
title="Stimme auswählen"
|
||||
groups={Object.entries(
|
||||
GERMAN_VOICES.reduce(
|
||||
(groups, voice) => {
|
||||
const provider = voice.provider;
|
||||
if (!groups[provider]) {
|
||||
groups[provider] = {};
|
||||
}
|
||||
const quality = voice.quality;
|
||||
if (!groups[provider][quality]) {
|
||||
groups[provider][quality] = [];
|
||||
}
|
||||
groups[provider][quality].push(voice);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
|
||||
)
|
||||
).map(([provider, qualityGroups]) => ({
|
||||
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
|
||||
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
|
||||
voices.map((voice) => ({
|
||||
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
|
||||
value: voice.value,
|
||||
}))
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>
|
||||
Geschwindigkeit
|
||||
</Text>
|
||||
<View className="space-y-2">
|
||||
{speeds.map((speed) => (
|
||||
<Pressable
|
||||
key={speed.value}
|
||||
onPress={() => updateSettings({ speed: speed.value })}
|
||||
className={`rounded-lg border p-3 ${
|
||||
settings.speed === speed.value
|
||||
? `border-blue-500 ${colors.primaryLight}`
|
||||
: colors.border
|
||||
}`}>
|
||||
<Text
|
||||
className={`${
|
||||
settings.speed === speed.value ? 'text-blue-700' : colors.textSecondary
|
||||
}`}>
|
||||
{speed.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>
|
||||
Geschwindigkeit
|
||||
</Text>
|
||||
<View className="space-y-2">
|
||||
{speeds.map((speed) => (
|
||||
<Pressable
|
||||
key={speed.value}
|
||||
onPress={() => updateSettings({ speed: speed.value })}
|
||||
className={`rounded-lg border p-3 ${
|
||||
settings.speed === speed.value
|
||||
? `border-blue-500 ${colors.primaryLight}`
|
||||
: colors.border
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`${
|
||||
settings.speed === speed.value ? 'text-blue-700' : colors.textSecondary
|
||||
}`}
|
||||
>
|
||||
{speed.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* App Settings */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>App-Einstellungen</Text>
|
||||
{/* App Settings */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>App-Einstellungen</Text>
|
||||
|
||||
<View>
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Design</Text>
|
||||
<View className="space-y-2">
|
||||
{themes.map((theme) => (
|
||||
<Pressable
|
||||
key={theme.value}
|
||||
onPress={() => updateSettings({ theme: theme.value as 'light' | 'dark' })}
|
||||
className={`rounded-lg border p-3 ${
|
||||
settings.theme === theme.value
|
||||
? `border-blue-500 ${colors.primaryLight}`
|
||||
: colors.border
|
||||
}`}>
|
||||
<Text
|
||||
className={`${
|
||||
settings.theme === theme.value ? 'text-blue-700' : colors.textSecondary
|
||||
}`}>
|
||||
{theme.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Design</Text>
|
||||
<View className="space-y-2">
|
||||
{themes.map((theme) => (
|
||||
<Pressable
|
||||
key={theme.value}
|
||||
onPress={() => updateSettings({ theme: theme.value as 'light' | 'dark' })}
|
||||
className={`rounded-lg border p-3 ${
|
||||
settings.theme === theme.value
|
||||
? `border-blue-500 ${colors.primaryLight}`
|
||||
: colors.border
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`${
|
||||
settings.theme === theme.value ? 'text-blue-700' : colors.textSecondary
|
||||
}`}
|
||||
>
|
||||
{theme.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* App Info */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>App Info</Text>
|
||||
{/* App Info */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>App Info</Text>
|
||||
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Version:</Text>
|
||||
<Text className={`${colors.text}`}>1.0.0</Text>
|
||||
</View>
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Version:</Text>
|
||||
<Text className={`${colors.text}`}>1.0.0</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Build:</Text>
|
||||
<Text className={`${colors.text}`}>1</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text className={`${colors.textSecondary}`}>Build:</Text>
|
||||
<Text className={`${colors.text}`}>1</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* User Info */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${colors.text}`}>Konto</Text>
|
||||
<Text className={`mb-4 ${colors.textSecondary}`}>{user?.email}</Text>
|
||||
<Pressable onPress={handleLogout} className={`rounded-lg ${colors.error} px-4 py-2`}>
|
||||
<Text className="text-center font-semibold text-white">Abmelden</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
{/* User Info */}
|
||||
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
|
||||
<Text className={`mb-2 text-lg font-semibold ${colors.text}`}>Konto</Text>
|
||||
<Text className={`mb-4 ${colors.textSecondary}`}>{user?.email}</Text>
|
||||
<Pressable onPress={handleLogout} className={`rounded-lg ${colors.error} px-4 py-2`}>
|
||||
<Text className="text-center font-semibold text-white">Abmelden</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,34 +5,34 @@ import { ScrollViewStyleReset } from 'expo-router/html';
|
|||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
|
|
|
|||
|
|
@ -3,22 +3,22 @@ import { Link, Stack } from 'expo-router';
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center p-5`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
container: `items-center flex-1 justify-center p-5`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,30 +6,30 @@ import { useAuth } from '~/hooks/useAuth';
|
|||
import { useEffect } from 'react';
|
||||
|
||||
if (typeof globalThis.structuredClone === 'undefined') {
|
||||
globalThis.structuredClone = (obj: any) => JSON.parse(JSON.stringify(obj));
|
||||
globalThis.structuredClone = (obj: any) => JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: '(tabs)',
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const { user, loading } = useAuth();
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (user) {
|
||||
router.replace('/(tabs)');
|
||||
} else {
|
||||
router.replace('/(auth)/login');
|
||||
}
|
||||
}
|
||||
}, [user, loading]);
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (user) {
|
||||
router.replace('/(tabs)');
|
||||
} else {
|
||||
router.replace('/(auth)/login');
|
||||
}
|
||||
}
|
||||
}, [user, loading]);
|
||||
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { Stack, router, useFocusEffect } from 'expo-router';
|
||||
import { useTexts } from '~/hooks/useTexts';
|
||||
|
|
@ -19,251 +19,256 @@ import { GERMAN_VOICES, QUALITY_LABELS, PROVIDER_LABELS, getVoiceById } from '~/
|
|||
import { urlExtractorService } from '~/services/urlExtractorService';
|
||||
|
||||
export default function AddTextScreen() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { createText, refetch } = useTexts();
|
||||
const { colors } = useTheme();
|
||||
const { settings } = useStore();
|
||||
const [selectedVoice, setSelectedVoice] = useState(settings.voice || 'de-DE-Neural2-A');
|
||||
const [inputMode, setInputMode] = useState<'text' | 'url'>('text');
|
||||
const [url, setUrl] = useState('');
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { createText, refetch } = useTexts();
|
||||
const { colors } = useTheme();
|
||||
const { settings } = useStore();
|
||||
const [selectedVoice, setSelectedVoice] = useState(settings.voice || 'de-DE-Neural2-A');
|
||||
const [inputMode, setInputMode] = useState<'text' | 'url'>('text');
|
||||
const [url, setUrl] = useState('');
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
|
||||
const handleExtractUrl = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('Bitte gib eine URL ein');
|
||||
return;
|
||||
}
|
||||
const handleExtractUrl = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('Bitte gib eine URL ein');
|
||||
return;
|
||||
}
|
||||
|
||||
setExtracting(true);
|
||||
setError(null);
|
||||
setExtracting(true);
|
||||
setError(null);
|
||||
|
||||
const { data, error: extractError } = await urlExtractorService.extractFromUrl(url);
|
||||
const { data, error: extractError } = await urlExtractorService.extractFromUrl(url);
|
||||
|
||||
setExtracting(false);
|
||||
setExtracting(false);
|
||||
|
||||
if (extractError) {
|
||||
setError(extractError.message);
|
||||
return;
|
||||
}
|
||||
if (extractError) {
|
||||
setError(extractError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setTitle(data.title);
|
||||
setContent(urlExtractorService.formatExtractedContent(data));
|
||||
if (data.tags.length > 0) {
|
||||
setTags(data.tags.join(', '));
|
||||
}
|
||||
}
|
||||
};
|
||||
if (data) {
|
||||
setTitle(data.title);
|
||||
setContent(urlExtractorService.formatExtractedContent(data));
|
||||
if (data.tags.length > 0) {
|
||||
setTags(data.tags.join(', '));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
setError('Bitte gib einen Titel ein');
|
||||
return;
|
||||
}
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
setError('Bitte gib einen Titel ein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
setError('Bitte gib einen Text ein');
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError('Bitte gib einen Text ein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const tagsArray = tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
const tagsArray = tags
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0);
|
||||
|
||||
try {
|
||||
const { data, error } = await createText(title.trim(), content.trim(), {
|
||||
tags: tagsArray,
|
||||
tts: { speed: settings.speed || 1.0, voice: selectedVoice },
|
||||
source: inputMode === 'url' ? url : undefined,
|
||||
});
|
||||
try {
|
||||
const { data, error } = await createText(title.trim(), content.trim(), {
|
||||
tags: tagsArray,
|
||||
tts: { speed: settings.speed || 1.0, voice: selectedVoice },
|
||||
source: inputMode === 'url' ? url : undefined,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating text:', error);
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.log('Text created successfully:', data);
|
||||
// Navigate back immediately - the list will refresh via useFocusEffect
|
||||
router.back();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unexpected error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unerwarteter Fehler');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (error) {
|
||||
console.error('Error creating text:', error);
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.log('Text created successfully:', data);
|
||||
// Navigate back immediately - the list will refresh via useFocusEffect
|
||||
router.back();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unexpected error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unerwarteter Fehler');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className={`flex-1 ${colors.background}`}>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header
|
||||
title="Neuer Text"
|
||||
rightComponent={
|
||||
<Pressable onPress={handleSave} disabled={loading}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#3B82F6" />
|
||||
) : (
|
||||
<Text className="font-semibold text-blue-600">Speichern</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className={`flex-1 ${colors.background}`}
|
||||
>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header
|
||||
title="Neuer Text"
|
||||
rightComponent={
|
||||
<Pressable onPress={handleSave} disabled={loading}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#3B82F6" />
|
||||
) : (
|
||||
<Text className="font-semibold text-blue-600">Speichern</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView className="flex-1 p-4">
|
||||
{error && (
|
||||
<View className={`mb-4 rounded-lg border border-red-200 ${colors.errorLight} p-3`}>
|
||||
<Text className="text-red-700">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
<ScrollView className="flex-1 p-4">
|
||||
{error && (
|
||||
<View className={`mb-4 rounded-lg border border-red-200 ${colors.errorLight} p-3`}>
|
||||
<Text className="text-red-700">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.text}`}>Titel</Text>
|
||||
<TextInput
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Titel des Textes"
|
||||
className={`rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.text}`}>Titel</Text>
|
||||
<TextInput
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Titel des Textes"
|
||||
className={`rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.text}`}>
|
||||
Tags (durch Komma getrennt)
|
||||
</Text>
|
||||
<TextInput
|
||||
value={tags}
|
||||
onChangeText={setTags}
|
||||
placeholder="z.B. Roman, Favorit, Entspannung"
|
||||
className={`rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.text}`}>
|
||||
Tags (durch Komma getrennt)
|
||||
</Text>
|
||||
<TextInput
|
||||
value={tags}
|
||||
onChangeText={setTags}
|
||||
placeholder="z.B. Roman, Favorit, Entspannung"
|
||||
className={`rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.text}`}>Stimme</Text>
|
||||
<Dropdown
|
||||
value={selectedVoice}
|
||||
onValueChange={setSelectedVoice}
|
||||
placeholder="Stimme wählen"
|
||||
title="Stimme auswählen"
|
||||
groups={Object.entries(
|
||||
GERMAN_VOICES.reduce(
|
||||
(groups, voice) => {
|
||||
const provider = voice.provider;
|
||||
if (!groups[provider]) {
|
||||
groups[provider] = {};
|
||||
}
|
||||
const quality = voice.quality;
|
||||
if (!groups[provider][quality]) {
|
||||
groups[provider][quality] = [];
|
||||
}
|
||||
groups[provider][quality].push(voice);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
|
||||
)
|
||||
).map(([provider, qualityGroups]) => ({
|
||||
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
|
||||
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
|
||||
voices.map((voice) => ({
|
||||
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
|
||||
value: voice.value,
|
||||
}))
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.text}`}>Stimme</Text>
|
||||
<Dropdown
|
||||
value={selectedVoice}
|
||||
onValueChange={setSelectedVoice}
|
||||
placeholder="Stimme wählen"
|
||||
title="Stimme auswählen"
|
||||
groups={Object.entries(
|
||||
GERMAN_VOICES.reduce(
|
||||
(groups, voice) => {
|
||||
const provider = voice.provider;
|
||||
if (!groups[provider]) {
|
||||
groups[provider] = {};
|
||||
}
|
||||
const quality = voice.quality;
|
||||
if (!groups[provider][quality]) {
|
||||
groups[provider][quality] = [];
|
||||
}
|
||||
groups[provider][quality].push(voice);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
|
||||
)
|
||||
).map(([provider, qualityGroups]) => ({
|
||||
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
|
||||
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
|
||||
voices.map((voice) => ({
|
||||
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
|
||||
value: voice.value,
|
||||
}))
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<View className="mb-2 flex-row">
|
||||
<Pressable
|
||||
onPress={() => setInputMode('text')}
|
||||
className={`mr-2 rounded-lg px-4 py-2 ${inputMode === 'text' ? colors.primary : colors.surface}`}>
|
||||
<Text className={inputMode === 'text' ? 'font-medium text-white' : `${colors.text}`}>
|
||||
Text
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setInputMode('url')}
|
||||
className={`rounded-lg px-4 py-2 ${inputMode === 'url' ? colors.primary : colors.surface}`}>
|
||||
<Text className={inputMode === 'url' ? 'font-medium text-white' : `${colors.text}`}>
|
||||
URL
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<View className="mb-2 flex-row">
|
||||
<Pressable
|
||||
onPress={() => setInputMode('text')}
|
||||
className={`mr-2 rounded-lg px-4 py-2 ${inputMode === 'text' ? colors.primary : colors.surface}`}
|
||||
>
|
||||
<Text className={inputMode === 'text' ? 'font-medium text-white' : `${colors.text}`}>
|
||||
Text
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setInputMode('url')}
|
||||
className={`rounded-lg px-4 py-2 ${inputMode === 'url' ? colors.primary : colors.surface}`}
|
||||
>
|
||||
<Text className={inputMode === 'url' ? 'font-medium text-white' : `${colors.text}`}>
|
||||
URL
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{inputMode === 'text' ? (
|
||||
<TextInput
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Füge hier deinen Text ein..."
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
className={`min-h-[200px] rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
|
||||
/>
|
||||
) : (
|
||||
<View>
|
||||
<TextInput
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://example.com/artikel"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
className={`rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text} mb-2`}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={handleExtractUrl}
|
||||
disabled={extracting || !url.trim()}
|
||||
className={`mb-2 rounded-lg px-4 py-3 ${
|
||||
extracting || !url.trim() ? 'bg-gray-300' : colors.primary
|
||||
}`}>
|
||||
{extracting ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Text className="text-center font-medium text-white">Text extrahieren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
{content && (
|
||||
<TextInput
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Extrahierter Text..."
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
className={`min-h-[150px] rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{inputMode === 'text' ? (
|
||||
<TextInput
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Füge hier deinen Text ein..."
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
className={`min-h-[200px] rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
|
||||
/>
|
||||
) : (
|
||||
<View>
|
||||
<TextInput
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://example.com/artikel"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
className={`rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text} mb-2`}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={handleExtractUrl}
|
||||
disabled={extracting || !url.trim()}
|
||||
className={`mb-2 rounded-lg px-4 py-3 ${
|
||||
extracting || !url.trim() ? 'bg-gray-300' : colors.primary
|
||||
}`}
|
||||
>
|
||||
{extracting ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Text className="text-center font-medium text-white">Text extrahieren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
{content && (
|
||||
<TextInput
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Extrahierter Text..."
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
className={`min-h-[150px] rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className={`mb-4 rounded-lg ${colors.surfaceSecondary} p-3`}>
|
||||
<Text className={`text-sm ${colors.textSecondary}`}>
|
||||
💡 Tipp: Du kannst später Audio für diesen Text generieren und offline anhören.
|
||||
</Text>
|
||||
</View>
|
||||
<View className={`mb-4 rounded-lg ${colors.surfaceSecondary} p-3`}>
|
||||
<Text className={`text-sm ${colors.textSecondary}`}>
|
||||
💡 Tipp: Du kannst später Audio für diesen Text generieren und offline anhören.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={loading}
|
||||
className={`mb-4 rounded-lg px-4 py-3 ${loading ? 'bg-gray-400' : colors.primary}`}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-center text-base font-semibold text-white">Speichern</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={loading}
|
||||
className={`mb-4 rounded-lg px-4 py-3 ${loading ? 'bg-gray-400' : colors.primary}`}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-center text-base font-semibold text-white">Speichern</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,204 +11,208 @@ import { Icon } from '~/components/Icon';
|
|||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export default function TextDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { texts, deleteText } = useTexts();
|
||||
const [text, setText] = useState<TextType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { colors } = useTheme();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { texts, deleteText } = useTexts();
|
||||
const [text, setText] = useState<TextType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { colors } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const foundText = texts.find((t) => t.id === id);
|
||||
setText(foundText || null);
|
||||
setLoading(false);
|
||||
}, [id, texts]);
|
||||
useEffect(() => {
|
||||
const foundText = texts.find((t) => t.id === id);
|
||||
setText(foundText || null);
|
||||
setLoading(false);
|
||||
}, [id, texts]);
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Text löschen',
|
||||
'Möchtest du diesen Text wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
if (text) {
|
||||
const { error } = await deleteText(text.id);
|
||||
if (!error) {
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Text löschen',
|
||||
'Möchtest du diesen Text wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
if (text) {
|
||||
const { error } = await deleteText(text.id);
|
||||
if (!error) {
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Text wird geladen..." />
|
||||
<View className={`flex-1 items-center justify-center ${colors.background}`}>
|
||||
<ActivityIndicator size="large" color="#3B82F6" />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Text wird geladen..." />
|
||||
<View className={`flex-1 items-center justify-center ${colors.background}`}>
|
||||
<ActivityIndicator size="large" color="#3B82F6" />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Text nicht gefunden" />
|
||||
<View className={`flex-1 items-center justify-center px-4 ${colors.background}`}>
|
||||
<Text variant="body" color="tertiary" align="center" className="mb-4">
|
||||
Der angeforderte Text wurde nicht gefunden.
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className={`rounded-lg ${colors.primary} px-4 py-2`}>
|
||||
<Text color="white">Zurück</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!text) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Text nicht gefunden" />
|
||||
<View className={`flex-1 items-center justify-center px-4 ${colors.background}`}>
|
||||
<Text variant="body" color="tertiary" align="center" className="mb-4">
|
||||
Der angeforderte Text wurde nicht gefunden.
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className={`rounded-lg ${colors.primary} px-4 py-2`}
|
||||
>
|
||||
<Text color="white">Zurück</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header
|
||||
title={text.title}
|
||||
rightComponent={
|
||||
<Pressable
|
||||
onPress={handleDelete}
|
||||
className="-mr-2 rounded-full p-2"
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Icon name="delete" size={24} color="#6b7280" />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header
|
||||
title={text.title}
|
||||
rightComponent={
|
||||
<Pressable
|
||||
onPress={handleDelete}
|
||||
className="-mr-2 rounded-full p-2"
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Icon name="delete" size={24} color="#6b7280" />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView className={`flex-1 ${colors.background}`}>
|
||||
<View className="p-4">
|
||||
<View className="mb-4">
|
||||
<Text variant="h3" className="mb-2">
|
||||
{text.title}
|
||||
</Text>
|
||||
<ScrollView className={`flex-1 ${colors.background}`}>
|
||||
<View className="p-4">
|
||||
<View className="mb-4">
|
||||
<Text variant="h3" className="mb-2">
|
||||
{text.title}
|
||||
</Text>
|
||||
|
||||
<View className="mb-2 flex-row items-center">
|
||||
<Text variant="bodySmall" color="tertiary">
|
||||
Erstellt: {formatDate(text.created_at)}
|
||||
</Text>
|
||||
{text.updated_at !== text.created_at && (
|
||||
<Text variant="bodySmall" color="tertiary" className="ml-4">
|
||||
Bearbeitet: {formatDate(text.updated_at)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="mb-2 flex-row items-center">
|
||||
<Text variant="bodySmall" color="tertiary">
|
||||
Erstellt: {formatDate(text.created_at)}
|
||||
</Text>
|
||||
{text.updated_at !== text.created_at && (
|
||||
<Text variant="bodySmall" color="tertiary" className="ml-4">
|
||||
Bearbeitet: {formatDate(text.updated_at)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{text.data.tags && text.data.tags.length > 0 ? (
|
||||
<View className="mb-4 flex-row flex-wrap">
|
||||
{text.data.tags.map((tag, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`mb-2 mr-2 rounded-full ${colors.primaryLight} px-3 py-1`}>
|
||||
<Text variant="bodySmall" color="blue">
|
||||
{tag}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{text.data.tags && text.data.tags.length > 0 ? (
|
||||
<View className="mb-4 flex-row flex-wrap">
|
||||
{text.data.tags.map((tag, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`mb-2 mr-2 rounded-full ${colors.primaryLight} px-3 py-1`}
|
||||
>
|
||||
<Text variant="bodySmall" color="blue">
|
||||
{tag}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="mb-6">
|
||||
<Text variant="body" className="leading-6">
|
||||
{text.content}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mb-6">
|
||||
<Text variant="body" className="leading-6">
|
||||
{text.content}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<AudioPlayer
|
||||
text={text}
|
||||
onAudioGenerated={() => {
|
||||
// Refresh text data after audio generation
|
||||
const updatedText = texts.find((t) => t.id === text.id);
|
||||
if (updatedText) {
|
||||
setText(updatedText);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AudioPlayer
|
||||
text={text}
|
||||
onAudioGenerated={() => {
|
||||
// Refresh text data after audio generation
|
||||
const updatedText = texts.find((t) => t.id === text.id);
|
||||
if (updatedText) {
|
||||
setText(updatedText);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{text.data.stats ? (
|
||||
<View className={`mt-6 rounded-lg ${colors.surfaceSecondary} p-4`}>
|
||||
<Text variant="h5" className="mb-3">
|
||||
Statistiken
|
||||
</Text>
|
||||
{text.data.stats ? (
|
||||
<View className={`mt-6 rounded-lg ${colors.surfaceSecondary} p-4`}>
|
||||
<Text variant="h5" className="mb-3">
|
||||
Statistiken
|
||||
</Text>
|
||||
|
||||
<View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Wiedergaben:</Text>
|
||||
<Text>{text.data.stats?.playCount || 0}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Wiedergaben:</Text>
|
||||
<Text>{text.data.stats?.playCount || 0}</Text>
|
||||
</View>
|
||||
|
||||
{text.data.stats?.totalTime ? (
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Gesamtzeit:</Text>
|
||||
<Text>
|
||||
{Math.floor(text.data.stats.totalTime / 60)}m {Math.round(text.data.stats.totalTime % 60)}s
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{text.data.stats?.totalTime ? (
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Gesamtzeit:</Text>
|
||||
<Text>
|
||||
{Math.floor(text.data.stats.totalTime / 60)}m{' '}
|
||||
{Math.round(text.data.stats.totalTime % 60)}s
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Status:</Text>
|
||||
<Text>{text.data.stats?.completed ? 'Abgeschlossen' : 'In Progress'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Status:</Text>
|
||||
<Text>{text.data.stats?.completed ? 'Abgeschlossen' : 'In Progress'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{text.data.audio?.hasLocalCache ? (
|
||||
<View className={`mt-6 rounded-lg ${colors.successLight} p-4`}>
|
||||
<Text variant="h5" className="mb-3">
|
||||
Audio Cache
|
||||
</Text>
|
||||
{text.data.audio?.hasLocalCache ? (
|
||||
<View className={`mt-6 rounded-lg ${colors.successLight} p-4`}>
|
||||
<Text variant="h5" className="mb-3">
|
||||
Audio Cache
|
||||
</Text>
|
||||
|
||||
<View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Chunks:</Text>
|
||||
<Text>{text.data.audio?.chunks?.length || 0}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Chunks:</Text>
|
||||
<Text>{text.data.audio?.chunks?.length || 0}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Größe:</Text>
|
||||
<Text>{((text.data.audio?.totalSize || 0) / 1024 / 1024).toFixed(2)} MB</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Größe:</Text>
|
||||
<Text>{((text.data.audio?.totalSize || 0) / 1024 / 1024).toFixed(2)} MB</Text>
|
||||
</View>
|
||||
|
||||
{text.data.audio?.lastGenerated ? (
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Generiert:</Text>
|
||||
<Text>{formatDate(text.data.audio.lastGenerated)}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
{text.data.audio?.lastGenerated ? (
|
||||
<View className="flex-row justify-between">
|
||||
<Text color="secondary">Generiert:</Text>
|
||||
<Text>{formatDate(text.data.audio.lastGenerated)}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,186 +1,189 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Platform,
|
||||
ActionSheetIOS,
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Platform,
|
||||
ActionSheetIOS,
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface ActionMenuOption {
|
||||
title: string;
|
||||
systemIcon?: string;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
systemIcon?: string;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ActionMenuProps {
|
||||
options: ActionMenuOption[];
|
||||
onSelect: (index: number) => void;
|
||||
children: React.ReactElement;
|
||||
title?: string;
|
||||
message?: string;
|
||||
options: ActionMenuOption[];
|
||||
onSelect: (index: number) => void;
|
||||
children: React.ReactElement;
|
||||
title?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function ActionMenu({ options, onSelect, children, title, message }: ActionMenuProps) {
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const { colors } = useTheme();
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const { colors } = useTheme();
|
||||
|
||||
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
'doc.text': 'document-text-outline',
|
||||
'play.circle': 'play-circle-outline',
|
||||
'square.and.arrow.up': 'share-outline',
|
||||
tag: 'pricetag-outline',
|
||||
trash: 'trash-outline',
|
||||
};
|
||||
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
'doc.text': 'document-text-outline',
|
||||
'play.circle': 'play-circle-outline',
|
||||
'square.and.arrow.up': 'share-outline',
|
||||
tag: 'pricetag-outline',
|
||||
trash: 'trash-outline',
|
||||
};
|
||||
|
||||
const showActionSheet = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
const optionTitles = options.map((opt) => opt.title);
|
||||
const destructiveButtonIndex = options.findIndex((opt) => opt.destructive);
|
||||
const disabledButtonIndices = options
|
||||
.map((opt, idx) => (opt.disabled ? idx : -1))
|
||||
.filter((idx) => idx !== -1);
|
||||
const showActionSheet = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
const optionTitles = options.map((opt) => opt.title);
|
||||
const destructiveButtonIndex = options.findIndex((opt) => opt.destructive);
|
||||
const disabledButtonIndices = options
|
||||
.map((opt, idx) => (opt.disabled ? idx : -1))
|
||||
.filter((idx) => idx !== -1);
|
||||
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options: [...optionTitles, 'Abbrechen'],
|
||||
cancelButtonIndex: optionTitles.length,
|
||||
destructiveButtonIndex: destructiveButtonIndex >= 0 ? destructiveButtonIndex : undefined,
|
||||
disabledButtonIndices,
|
||||
title,
|
||||
message,
|
||||
},
|
||||
(buttonIndex) => {
|
||||
if (buttonIndex !== optionTitles.length) {
|
||||
onSelect(buttonIndex);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options: [...optionTitles, 'Abbrechen'],
|
||||
cancelButtonIndex: optionTitles.length,
|
||||
destructiveButtonIndex: destructiveButtonIndex >= 0 ? destructiveButtonIndex : undefined,
|
||||
disabledButtonIndices,
|
||||
title,
|
||||
message,
|
||||
},
|
||||
(buttonIndex) => {
|
||||
if (buttonIndex !== optionTitles.length) {
|
||||
onSelect(buttonIndex);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (index: number) => {
|
||||
setVisible(false);
|
||||
setTimeout(() => onSelect(index), 100);
|
||||
};
|
||||
const handleSelect = (index: number) => {
|
||||
setVisible(false);
|
||||
setTimeout(() => onSelect(index), 100);
|
||||
};
|
||||
|
||||
const renderOption = ({ item, index }: { item: ActionMenuOption; index: number }) => {
|
||||
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
|
||||
const isDisabled = item.disabled;
|
||||
const isDestructive = item.destructive;
|
||||
const renderOption = ({ item, index }: { item: ActionMenuOption; index: number }) => {
|
||||
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
|
||||
const isDisabled = item.disabled;
|
||||
const isDestructive = item.destructive;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !isDisabled && handleSelect(index)}
|
||||
disabled={isDisabled}
|
||||
className={`flex-row items-center px-4 py-4`}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
})}>
|
||||
{iconName && (
|
||||
<Ionicons
|
||||
name={iconName}
|
||||
size={22}
|
||||
color={
|
||||
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
|
||||
}
|
||||
style={{ marginRight: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Text className={`text-lg ${isDestructive ? 'text-red-500' : colors.text}`}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !isDisabled && handleSelect(index)}
|
||||
disabled={isDisabled}
|
||||
className={`flex-row items-center px-4 py-4`}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
{iconName && (
|
||||
<Ionicons
|
||||
name={iconName}
|
||||
size={22}
|
||||
color={
|
||||
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
|
||||
}
|
||||
style={{ marginRight: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Text className={`text-lg ${isDestructive ? 'text-red-500' : colors.text}`}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
onLongPress: showActionSheet,
|
||||
delayLongPress: 500,
|
||||
} as any)}
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement(children, {
|
||||
onLongPress: showActionSheet,
|
||||
delayLongPress: 500,
|
||||
} as any)}
|
||||
|
||||
{Platform.OS !== 'ios' && (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setVisible(false)}>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
|
||||
<View style={styles.backdrop} />
|
||||
{Platform.OS !== 'ios' && (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setVisible(false)}
|
||||
>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
|
||||
<View style={styles.backdrop} />
|
||||
|
||||
<View style={styles.container}>
|
||||
<View className={`rounded-t-2xl ${colors.surface}`} style={styles.menu}>
|
||||
{(title || message) && (
|
||||
<View className={`border-b px-4 py-3 ${colors.border}`}>
|
||||
{title && (
|
||||
<Text className={`text-center font-semibold ${colors.text}`}>{title}</Text>
|
||||
)}
|
||||
{message && (
|
||||
<Text className={`mt-1 text-center text-sm ${colors.textSecondary}`}>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.container}>
|
||||
<View className={`rounded-t-2xl ${colors.surface}`} style={styles.menu}>
|
||||
{(title || message) && (
|
||||
<View className={`border-b px-4 py-3 ${colors.border}`}>
|
||||
{title && (
|
||||
<Text className={`text-center font-semibold ${colors.text}`}>{title}</Text>
|
||||
)}
|
||||
{message && (
|
||||
<Text className={`mt-1 text-center text-sm ${colors.textSecondary}`}>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={options}
|
||||
renderItem={renderOption}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
scrollEnabled={false}
|
||||
ItemSeparatorComponent={() => <View className={`h-px ${colors.border}`} />}
|
||||
/>
|
||||
<FlatList
|
||||
data={options}
|
||||
renderItem={renderOption}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
scrollEnabled={false}
|
||||
ItemSeparatorComponent={() => <View className={`h-px ${colors.border}`} />}
|
||||
/>
|
||||
|
||||
<View className={`border-t ${colors.border}`}>
|
||||
<Pressable
|
||||
onPress={() => setVisible(false)}
|
||||
className="py-4"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
})}>
|
||||
<Text className="text-center text-lg font-medium text-blue-600">Abbrechen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
<View className={`border-t ${colors.border}`}>
|
||||
<Pressable
|
||||
onPress={() => setVisible(false)}
|
||||
className="py-4"
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
})}
|
||||
>
|
||||
<Text className="text-center text-lg font-medium text-blue-600">Abbrechen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
menu: {
|
||||
maxHeight: '80%',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
// @ts-ignore - React Native Web supports boxShadow
|
||||
boxShadow: '0px -2px 8px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
android: {
|
||||
elevation: 16,
|
||||
},
|
||||
}),
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
menu: {
|
||||
maxHeight: '80%',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
// @ts-ignore - React Native Web supports boxShadow
|
||||
boxShadow: '0px -2px 8px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
android: {
|
||||
elevation: 16,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Animated,
|
||||
ScrollView,
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Animated,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAudio } from '~/hooks/useAudio';
|
||||
|
|
@ -14,456 +14,476 @@ import { Text as TextType, AudioVersion } from '~/types/database';
|
|||
import { useStore } from '~/store/store';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
import { Dropdown } from '~/components/dropdown';
|
||||
import { Voice, ALL_VOICES, getVoiceById, GERMAN_VOICES, PROVIDER_LABELS, QUALITY_LABELS } from '~/constants/voices';
|
||||
import {
|
||||
Voice,
|
||||
ALL_VOICES,
|
||||
getVoiceById,
|
||||
GERMAN_VOICES,
|
||||
PROVIDER_LABELS,
|
||||
QUALITY_LABELS,
|
||||
} from '~/constants/voices';
|
||||
import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
text: TextType;
|
||||
onAudioGenerated?: () => void;
|
||||
text: TextType;
|
||||
onAudioGenerated?: () => void;
|
||||
}
|
||||
|
||||
export const AudioPlayer: React.FC<AudioPlayerProps> = ({ text, onAudioGenerated }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [showSpeedControl, setShowSpeedControl] = useState(false);
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>('');
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const progressBarRef = useRef<View>(null);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [showSpeedControl, setShowSpeedControl] = useState(false);
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>('');
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const progressBarRef = useRef<View>(null);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const { settings, updateSettings } = useStore();
|
||||
const { colors } = useTheme();
|
||||
const { settings, updateSettings } = useStore();
|
||||
const { colors } = useTheme();
|
||||
|
||||
// Use useMemo to prevent re-migration on every render
|
||||
const migratedData = useMemo(() => migrateAudioData(text.data), [text.data]);
|
||||
const audioVersions = migratedData.audioVersions || [];
|
||||
const currentVersion = useMemo(() => getCurrentAudioVersion(migratedData), [migratedData]);
|
||||
// Use useMemo to prevent re-migration on every render
|
||||
const migratedData = useMemo(() => migrateAudioData(text.data), [text.data]);
|
||||
const audioVersions = migratedData.audioVersions || [];
|
||||
const currentVersion = useMemo(() => getCurrentAudioVersion(migratedData), [migratedData]);
|
||||
|
||||
// Initialize selectedVersionId with current version
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string>(currentVersion?.id || '');
|
||||
// Initialize selectedVersionId with current version
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string>(currentVersion?.id || '');
|
||||
|
||||
// Initialize selected voice
|
||||
useEffect(() => {
|
||||
setSelectedVoice(settings.voice);
|
||||
}, [settings.voice]);
|
||||
// Initialize selected voice
|
||||
useEffect(() => {
|
||||
setSelectedVoice(settings.voice);
|
||||
}, [settings.voice]);
|
||||
|
||||
const {
|
||||
audioState,
|
||||
generationProgress,
|
||||
generateAudio,
|
||||
playAudio,
|
||||
pauseAudio,
|
||||
resumeAudio,
|
||||
stopAudio,
|
||||
seekTo,
|
||||
seekForward,
|
||||
seekBackward,
|
||||
setPlaybackSpeed,
|
||||
clearCache,
|
||||
} = useAudio();
|
||||
const {
|
||||
audioState,
|
||||
generationProgress,
|
||||
generateAudio,
|
||||
playAudio,
|
||||
pauseAudio,
|
||||
resumeAudio,
|
||||
stopAudio,
|
||||
seekTo,
|
||||
seekForward,
|
||||
seekBackward,
|
||||
setPlaybackSpeed,
|
||||
clearCache,
|
||||
} = useAudio();
|
||||
|
||||
// Pulsating animation for loading state
|
||||
useEffect(() => {
|
||||
if (audioState.isLoading) {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.2,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
} else {
|
||||
pulseAnim.setValue(1);
|
||||
}
|
||||
}, [audioState.isLoading, pulseAnim]);
|
||||
// Pulsating animation for loading state
|
||||
useEffect(() => {
|
||||
if (audioState.isLoading) {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.2,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
} else {
|
||||
pulseAnim.setValue(1);
|
||||
}
|
||||
}, [audioState.isLoading, pulseAnim]);
|
||||
|
||||
const handleGenerateAudio = async () => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
const handleGenerateAudio = async () => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
await generateAudio(text.id, text.content, selectedVoice, settings.speed, text);
|
||||
await generateAudio(text.id, text.content, selectedVoice, settings.speed, text);
|
||||
|
||||
onAudioGenerated?.();
|
||||
onAudioGenerated?.();
|
||||
|
||||
Alert.alert(
|
||||
'Audio generiert!',
|
||||
'Das Audio wurde erfolgreich generiert und ist jetzt verfügbar.'
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Fehler',
|
||||
error instanceof Error ? error.message : 'Fehler beim Generieren des Audios'
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
Alert.alert(
|
||||
'Audio generiert!',
|
||||
'Das Audio wurde erfolgreich generiert und ist jetzt verfügbar.'
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Fehler',
|
||||
error instanceof Error ? error.message : 'Fehler beim Generieren des Audios'
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoiceChange = (newVoice: string) => {
|
||||
setSelectedVoice(newVoice);
|
||||
// Update the global settings
|
||||
updateSettings({ voice: newVoice });
|
||||
};
|
||||
const handleVoiceChange = (newVoice: string) => {
|
||||
setSelectedVoice(newVoice);
|
||||
// Update the global settings
|
||||
updateSettings({ voice: newVoice });
|
||||
};
|
||||
|
||||
const handlePlayPause = async () => {
|
||||
if (!selectedVersion?.chunks) return;
|
||||
const handlePlayPause = async () => {
|
||||
if (!selectedVersion?.chunks) return;
|
||||
|
||||
try {
|
||||
if (audioState.isPlaying) {
|
||||
await pauseAudio();
|
||||
} else if (audioState.sound) {
|
||||
await resumeAudio();
|
||||
} else {
|
||||
// Play directly from Supabase Storage
|
||||
await playAudio(text.id, selectedVersion.chunks, text.data.tts?.lastPosition || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Wiedergabe-Fehler',
|
||||
error instanceof Error ? error.message : 'Fehler beim Abspielen des Audios'
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (audioState.isPlaying) {
|
||||
await pauseAudio();
|
||||
} else if (audioState.sound) {
|
||||
await resumeAudio();
|
||||
} else {
|
||||
// Play directly from Supabase Storage
|
||||
await playAudio(text.id, selectedVersion.chunks, text.data.tts?.lastPosition || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Wiedergabe-Fehler',
|
||||
error instanceof Error ? error.message : 'Fehler beim Abspielen des Audios'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
await stopAudio();
|
||||
};
|
||||
const handleStop = async () => {
|
||||
await stopAudio();
|
||||
};
|
||||
|
||||
const formatTime = (milliseconds: number): string => {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
const formatTime = (milliseconds: number): string => {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
const formatSize = (bytes: number): string => {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const speedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
const speedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
const handleSpeedChange = async (speed: number) => {
|
||||
await setPlaybackSpeed(speed);
|
||||
setShowSpeedControl(false);
|
||||
};
|
||||
const handleSpeedChange = async (speed: number) => {
|
||||
await setPlaybackSpeed(speed);
|
||||
setShowSpeedControl(false);
|
||||
};
|
||||
|
||||
// Use duration from audio state if available, otherwise calculate from chunks
|
||||
const totalDuration =
|
||||
audioState.duration ||
|
||||
(selectedVersion?.chunks
|
||||
? selectedVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000
|
||||
: 0);
|
||||
// Use duration from audio state if available, otherwise calculate from chunks
|
||||
const totalDuration =
|
||||
audioState.duration ||
|
||||
(selectedVersion?.chunks
|
||||
? selectedVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000
|
||||
: 0);
|
||||
|
||||
// Handle progress bar press
|
||||
const handleProgressPress = async (event: any) => {
|
||||
if (progressBarRef.current && totalDuration > 0) {
|
||||
progressBarRef.current.measure(async (x, y, width, height, pageX, pageY) => {
|
||||
const touchX = event.nativeEvent.pageX - pageX;
|
||||
const progress = Math.max(0, Math.min(1, touchX / width));
|
||||
const newPosition = progress * totalDuration;
|
||||
// Handle progress bar press
|
||||
const handleProgressPress = async (event: any) => {
|
||||
if (progressBarRef.current && totalDuration > 0) {
|
||||
progressBarRef.current.measure(async (x, y, width, height, pageX, pageY) => {
|
||||
const touchX = event.nativeEvent.pageX - pageX;
|
||||
const progress = Math.max(0, Math.min(1, touchX / width));
|
||||
const newPosition = progress * totalDuration;
|
||||
|
||||
// If audio hasn't been started yet, start it at the desired position
|
||||
if (!audioState.sound) {
|
||||
await playAudio(text.id, text.data.audio!.chunks, newPosition);
|
||||
} else {
|
||||
await seekTo(newPosition);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
// If audio hasn't been started yet, start it at the desired position
|
||||
if (!audioState.sound) {
|
||||
await playAudio(text.id, text.data.audio!.chunks, newPosition);
|
||||
} else {
|
||||
await seekTo(newPosition);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get the selected audio version
|
||||
const selectedVersion = audioVersions.find((v) => v.id === selectedVersionId) || currentVersion;
|
||||
const hasAudio = selectedVersion && selectedVersion.chunks.length > 0;
|
||||
// Get the selected audio version
|
||||
const selectedVersion = audioVersions.find((v) => v.id === selectedVersionId) || currentVersion;
|
||||
const hasAudio = selectedVersion && selectedVersion.chunks.length > 0;
|
||||
|
||||
return (
|
||||
<View className={`rounded-lg ${colors.surface} p-3 shadow-sm`}>
|
||||
{/* Voice selection and generate button - always visible */}
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Sprachauswahl</Text>
|
||||
<Dropdown
|
||||
options={[]}
|
||||
value={selectedVoice}
|
||||
onValueChange={handleVoiceChange}
|
||||
placeholder="Wähle eine Stimme"
|
||||
disabled={isGenerating}
|
||||
title="Stimme auswählen"
|
||||
groups={Object.entries(
|
||||
GERMAN_VOICES.reduce(
|
||||
(groups, voice) => {
|
||||
const provider = voice.provider;
|
||||
const quality = voice.quality;
|
||||
if (!groups[provider]) {
|
||||
groups[provider] = {};
|
||||
}
|
||||
if (!groups[provider][quality]) {
|
||||
groups[provider][quality] = [];
|
||||
}
|
||||
groups[provider][quality].push(voice);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
|
||||
)
|
||||
).map(([provider, qualityGroups]) => ({
|
||||
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
|
||||
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
|
||||
voices.map((voice) => ({
|
||||
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
|
||||
value: voice.value,
|
||||
}))
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
return (
|
||||
<View className={`rounded-lg ${colors.surface} p-3 shadow-sm`}>
|
||||
{/* Voice selection and generate button - always visible */}
|
||||
<View className="mb-4">
|
||||
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Sprachauswahl</Text>
|
||||
<Dropdown
|
||||
options={[]}
|
||||
value={selectedVoice}
|
||||
onValueChange={handleVoiceChange}
|
||||
placeholder="Wähle eine Stimme"
|
||||
disabled={isGenerating}
|
||||
title="Stimme auswählen"
|
||||
groups={Object.entries(
|
||||
GERMAN_VOICES.reduce(
|
||||
(groups, voice) => {
|
||||
const provider = voice.provider;
|
||||
const quality = voice.quality;
|
||||
if (!groups[provider]) {
|
||||
groups[provider] = {};
|
||||
}
|
||||
if (!groups[provider][quality]) {
|
||||
groups[provider][quality] = [];
|
||||
}
|
||||
groups[provider][quality].push(voice);
|
||||
return groups;
|
||||
},
|
||||
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
|
||||
)
|
||||
).map(([provider, qualityGroups]) => ({
|
||||
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
|
||||
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
|
||||
voices.map((voice) => ({
|
||||
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
|
||||
value: voice.value,
|
||||
}))
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Pressable
|
||||
onPress={handleGenerateAudio}
|
||||
disabled={isGenerating}
|
||||
className={`mt-3 rounded-lg px-4 py-2.5 ${
|
||||
isGenerating ? 'bg-gray-400' : colors.primary
|
||||
}`}>
|
||||
{isGenerating ? (
|
||||
<View className="flex-row items-center justify-center">
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
<Text className="ml-2 font-medium text-white">
|
||||
{generationProgress?.currentChunk || 'Generiere Audio...'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row items-center justify-center">
|
||||
<Ionicons name="volume-high" size={20} color="white" />
|
||||
<Text className="ml-2 font-medium text-white">
|
||||
{hasAudio ? 'Audio neu generieren' : 'Audio generieren'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleGenerateAudio}
|
||||
disabled={isGenerating}
|
||||
className={`mt-3 rounded-lg px-4 py-2.5 ${isGenerating ? 'bg-gray-400' : colors.primary}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<View className="flex-row items-center justify-center">
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
<Text className="ml-2 font-medium text-white">
|
||||
{generationProgress?.currentChunk || 'Generiere Audio...'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row items-center justify-center">
|
||||
<Ionicons name="volume-high" size={20} color="white" />
|
||||
<Text className="ml-2 font-medium text-white">
|
||||
{hasAudio ? 'Audio neu generieren' : 'Audio generieren'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{generationProgress && (
|
||||
<View className="mt-2">
|
||||
<View className={`h-1.5 rounded-full ${colors.surfaceSecondary}`}>
|
||||
<View
|
||||
className={`h-1.5 rounded-full ${colors.primary}`}
|
||||
style={{
|
||||
width: `${(generationProgress.chunksCompleted / generationProgress.totalChunks) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text className={`mt-1 text-xs ${colors.textSecondary}`}>
|
||||
{generationProgress.chunksCompleted} / {generationProgress.totalChunks} Chunks
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{generationProgress && (
|
||||
<View className="mt-2">
|
||||
<View className={`h-1.5 rounded-full ${colors.surfaceSecondary}`}>
|
||||
<View
|
||||
className={`h-1.5 rounded-full ${colors.primary}`}
|
||||
style={{
|
||||
width: `${(generationProgress.chunksCompleted / generationProgress.totalChunks) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text className={`mt-1 text-xs ${colors.textSecondary}`}>
|
||||
{generationProgress.chunksCompleted} / {generationProgress.totalChunks} Chunks
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Audio versions - only shown when audio exists */}
|
||||
{audioVersions.length > 0 && (
|
||||
<View className="mt-4">
|
||||
<Pressable
|
||||
onPress={() => setShowVersions(!showVersions)}
|
||||
className="flex-row items-center justify-between">
|
||||
<Text className={`text-sm font-medium ${colors.textSecondary}`}>
|
||||
Audio-Versionen ({audioVersions.length})
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={showVersions ? 'chevron-up' : 'chevron-down'}
|
||||
size={16}
|
||||
color="#71717a"
|
||||
/>
|
||||
</Pressable>
|
||||
{/* Audio versions - only shown when audio exists */}
|
||||
{audioVersions.length > 0 && (
|
||||
<View className="mt-4">
|
||||
<Pressable
|
||||
onPress={() => setShowVersions(!showVersions)}
|
||||
className="flex-row items-center justify-between"
|
||||
>
|
||||
<Text className={`text-sm font-medium ${colors.textSecondary}`}>
|
||||
Audio-Versionen ({audioVersions.length})
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={showVersions ? 'chevron-up' : 'chevron-down'}
|
||||
size={16}
|
||||
color="#71717a"
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{showVersions && (
|
||||
<ScrollView className="mt-2 max-h-40">
|
||||
{audioVersions.map((version) => {
|
||||
const voice = getVoiceById(version.settings.voice);
|
||||
const isActive = version.id === selectedVersionId;
|
||||
const date = new Date(version.createdAt);
|
||||
{showVersions && (
|
||||
<ScrollView className="mt-2 max-h-40">
|
||||
{audioVersions.map((version) => {
|
||||
const voice = getVoiceById(version.settings.voice);
|
||||
const isActive = version.id === selectedVersionId;
|
||||
const date = new Date(version.createdAt);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={version.id}
|
||||
onPress={() => setSelectedVersionId(version.id)}
|
||||
className={`mb-2 rounded-lg p-3 ${
|
||||
isActive ? 'bg-blue-600' : colors.surfaceSecondary
|
||||
}`}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className={`text-sm ${isActive ? 'text-white' : colors.text}`}>
|
||||
{date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-xs ${isActive ? 'text-blue-100' : colors.textSecondary}`}>
|
||||
{voice?.label || version.settings.voice} • {version.settings.speed}x
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
{isActive && <Text className="mr-2 text-xs text-white">Aktiv</Text>}
|
||||
<Ionicons
|
||||
name={isActive ? 'radio-button-on' : 'radio-button-off'}
|
||||
size={20}
|
||||
color={isActive ? 'white' : '#71717a'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
return (
|
||||
<Pressable
|
||||
key={version.id}
|
||||
onPress={() => setSelectedVersionId(version.id)}
|
||||
className={`mb-2 rounded-lg p-3 ${
|
||||
isActive ? 'bg-blue-600' : colors.surfaceSecondary
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className={`text-sm ${isActive ? 'text-white' : colors.text}`}>
|
||||
{date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-xs ${isActive ? 'text-blue-100' : colors.textSecondary}`}
|
||||
>
|
||||
{voice?.label || version.settings.voice} • {version.settings.speed}x
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
{isActive && <Text className="mr-2 text-xs text-white">Aktiv</Text>}
|
||||
<Ionicons
|
||||
name={isActive ? 'radio-button-on' : 'radio-button-off'}
|
||||
size={20}
|
||||
color={isActive ? 'white' : '#71717a'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Audio player - only shown when audio exists */}
|
||||
{hasAudio && (
|
||||
<View className="mt-4 border-t border-zinc-800 pt-3">
|
||||
{/* </View> closing tag moved to end */}
|
||||
{/* Progress bar and time info - full width */}
|
||||
<View className="mb-3">
|
||||
{/* Progress Bar with touch gestures */}
|
||||
<Pressable onPress={handleProgressPress} className="py-2">
|
||||
<View
|
||||
ref={progressBarRef}
|
||||
className={`h-2 rounded-full ${colors.surfaceSecondary} overflow-hidden`}>
|
||||
<View
|
||||
className={`h-2 rounded-full ${colors.primary}`}
|
||||
style={{
|
||||
width:
|
||||
totalDuration > 0
|
||||
? `${(audioState.currentPosition / totalDuration) * 100}%`
|
||||
: '0%',
|
||||
}}
|
||||
/>
|
||||
{/* Scrubber indicator */}
|
||||
{totalDuration > 0 && (
|
||||
<View
|
||||
className="absolute top-0 h-2"
|
||||
style={{
|
||||
left: `${(audioState.currentPosition / totalDuration) * 100}%`,
|
||||
}}>
|
||||
<View
|
||||
className={`h-3 w-3 rounded-full ${colors.primary} shadow-lg`}
|
||||
style={{ marginTop: -2, marginLeft: -6 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
{/* Audio player - only shown when audio exists */}
|
||||
{hasAudio && (
|
||||
<View className="mt-4 border-t border-zinc-800 pt-3">
|
||||
{/* </View> closing tag moved to end */}
|
||||
{/* Progress bar and time info - full width */}
|
||||
<View className="mb-3">
|
||||
{/* Progress Bar with touch gestures */}
|
||||
<Pressable onPress={handleProgressPress} className="py-2">
|
||||
<View
|
||||
ref={progressBarRef}
|
||||
className={`h-2 rounded-full ${colors.surfaceSecondary} overflow-hidden`}
|
||||
>
|
||||
<View
|
||||
className={`h-2 rounded-full ${colors.primary}`}
|
||||
style={{
|
||||
width:
|
||||
totalDuration > 0
|
||||
? `${(audioState.currentPosition / totalDuration) * 100}%`
|
||||
: '0%',
|
||||
}}
|
||||
/>
|
||||
{/* Scrubber indicator */}
|
||||
{totalDuration > 0 && (
|
||||
<View
|
||||
className="absolute top-0 h-2"
|
||||
style={{
|
||||
left: `${(audioState.currentPosition / totalDuration) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`h-3 w-3 rounded-full ${colors.primary} shadow-lg`}
|
||||
style={{ marginTop: -2, marginLeft: -6 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Time display */}
|
||||
<View className="mt-1 flex-row justify-between">
|
||||
<Text className={`text-xs ${colors.textTertiary}`}>
|
||||
{formatTime(audioState.currentPosition)}
|
||||
</Text>
|
||||
<Text className={`text-xs ${colors.textTertiary}`}>{formatTime(totalDuration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* Time display */}
|
||||
<View className="mt-1 flex-row justify-between">
|
||||
<Text className={`text-xs ${colors.textTertiary}`}>
|
||||
{formatTime(audioState.currentPosition)}
|
||||
</Text>
|
||||
<Text className={`text-xs ${colors.textTertiary}`}>{formatTime(totalDuration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Controls row */}
|
||||
<View className="flex-row items-center justify-center">
|
||||
{/* Stop button */}
|
||||
<Pressable
|
||||
onPress={handleStop}
|
||||
disabled={audioState.isLoading}
|
||||
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}>
|
||||
<Ionicons name="stop" size={18} color="#6b7280" />
|
||||
</Pressable>
|
||||
{/* Controls row */}
|
||||
<View className="flex-row items-center justify-center">
|
||||
{/* Stop button */}
|
||||
<Pressable
|
||||
onPress={handleStop}
|
||||
disabled={audioState.isLoading}
|
||||
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}
|
||||
>
|
||||
<Ionicons name="stop" size={18} color="#6b7280" />
|
||||
</Pressable>
|
||||
|
||||
{/* Backward 15s button */}
|
||||
<Pressable
|
||||
onPress={() => seekBackward(15)}
|
||||
disabled={audioState.isLoading || !audioState.sound}
|
||||
className={`rounded-full ${colors.surfaceSecondary} mr-2 p-2`}>
|
||||
<View className="relative" style={{ transform: [{ scaleX: -1 }] }}>
|
||||
<Ionicons name="reload" size={18} color="#6b7280" />
|
||||
<View
|
||||
className="absolute -bottom-1 -left-1"
|
||||
style={{ transform: [{ scaleX: -1 }] }}>
|
||||
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
{/* Backward 15s button */}
|
||||
<Pressable
|
||||
onPress={() => seekBackward(15)}
|
||||
disabled={audioState.isLoading || !audioState.sound}
|
||||
className={`rounded-full ${colors.surfaceSecondary} mr-2 p-2`}
|
||||
>
|
||||
<View className="relative" style={{ transform: [{ scaleX: -1 }] }}>
|
||||
<Ionicons name="reload" size={18} color="#6b7280" />
|
||||
<View
|
||||
className="absolute -bottom-1 -left-1"
|
||||
style={{ transform: [{ scaleX: -1 }] }}
|
||||
>
|
||||
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Play/Pause button */}
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: audioState.isLoading ? pulseAnim : 1 }],
|
||||
}}>
|
||||
<Pressable
|
||||
onPress={handlePlayPause}
|
||||
disabled={audioState.isLoading}
|
||||
className={`rounded-full ${colors.primary} mx-2 p-2.5`}>
|
||||
{audioState.isLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={audioState.isPlaying ? 'pause' : 'play'}
|
||||
size={20}
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
{/* Play/Pause button */}
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: audioState.isLoading ? pulseAnim : 1 }],
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={handlePlayPause}
|
||||
disabled={audioState.isLoading}
|
||||
className={`rounded-full ${colors.primary} mx-2 p-2.5`}
|
||||
>
|
||||
{audioState.isLoading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={audioState.isPlaying ? 'pause' : 'play'}
|
||||
size={20}
|
||||
color="white"
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
{/* Forward 15s button */}
|
||||
<Pressable
|
||||
onPress={() => seekForward(15)}
|
||||
disabled={audioState.isLoading || !audioState.sound}
|
||||
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}>
|
||||
<View className="relative">
|
||||
<Ionicons name="reload" size={18} color="#6b7280" />
|
||||
<View className="absolute -bottom-1 -right-1">
|
||||
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
{/* Forward 15s button */}
|
||||
<Pressable
|
||||
onPress={() => seekForward(15)}
|
||||
disabled={audioState.isLoading || !audioState.sound}
|
||||
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}
|
||||
>
|
||||
<View className="relative">
|
||||
<Ionicons name="reload" size={18} color="#6b7280" />
|
||||
<View className="absolute -bottom-1 -right-1">
|
||||
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Speed control button */}
|
||||
<Pressable
|
||||
onPress={() => setShowSpeedControl(!showSpeedControl)}
|
||||
className={`rounded-full ${colors.surfaceSecondary} px-3 py-1.5`}>
|
||||
<Text style={{ fontSize: 14, color: '#6b7280', fontWeight: '600' }}>
|
||||
{audioState.playbackRate}x
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{/* Speed control button */}
|
||||
<Pressable
|
||||
onPress={() => setShowSpeedControl(!showSpeedControl)}
|
||||
className={`rounded-full ${colors.surfaceSecondary} px-3 py-1.5`}
|
||||
>
|
||||
<Text style={{ fontSize: 14, color: '#6b7280', fontWeight: '600' }}>
|
||||
{audioState.playbackRate}x
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Speed options dropdown */}
|
||||
{showSpeedControl && (
|
||||
<View className="mt-2 flex-row justify-center">
|
||||
<View className={`rounded-lg ${colors.surfaceSecondary} flex-row p-2`}>
|
||||
{speedOptions.map((speed) => (
|
||||
<Pressable
|
||||
key={speed}
|
||||
onPress={() => handleSpeedChange(speed)}
|
||||
className={`mx-1 rounded px-3 py-1 ${
|
||||
audioState.playbackRate === speed ? colors.primary : ''
|
||||
}`}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: audioState.playbackRate === speed ? '#ffffff' : '#6b7280',
|
||||
fontWeight: audioState.playbackRate === speed ? 'bold' : 'normal',
|
||||
}}>
|
||||
{speed}x
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
{/* Speed options dropdown */}
|
||||
{showSpeedControl && (
|
||||
<View className="mt-2 flex-row justify-center">
|
||||
<View className={`rounded-lg ${colors.surfaceSecondary} flex-row p-2`}>
|
||||
{speedOptions.map((speed) => (
|
||||
<Pressable
|
||||
key={speed}
|
||||
onPress={() => handleSpeedChange(speed)}
|
||||
className={`mx-1 rounded px-3 py-1 ${
|
||||
audioState.playbackRate === speed ? colors.primary : ''
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: audioState.playbackRate === speed ? '#ffffff' : '#6b7280',
|
||||
fontWeight: audioState.playbackRate === speed ? 'bold' : 'normal',
|
||||
}}
|
||||
>
|
||||
{speed}x
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,204 +4,204 @@ import { Icon, IconName } from './Icon';
|
|||
import { Text } from './Text';
|
||||
|
||||
export type ButtonVariant =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| 'link'
|
||||
| 'destructive'
|
||||
| 'success'
|
||||
| 'warning';
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| 'link'
|
||||
| 'destructive'
|
||||
| 'success'
|
||||
| 'warning';
|
||||
|
||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
interface ButtonProps extends Omit<PressableProps, 'children'> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: IconName;
|
||||
iconPosition?: 'left' | 'right';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
icon?: IconName;
|
||||
iconPosition?: 'left' | 'right';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
fullWidth = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
fullWidth = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const isDisabled = disabled || loading;
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
// Get variant styles
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return 'bg-blue-600 active:bg-blue-700';
|
||||
case 'secondary':
|
||||
return 'bg-gray-600 active:bg-gray-700';
|
||||
case 'outline':
|
||||
return 'border border-gray-300 bg-white active:bg-gray-50';
|
||||
case 'ghost':
|
||||
return 'bg-transparent active:bg-gray-100';
|
||||
case 'link':
|
||||
return 'bg-transparent';
|
||||
case 'destructive':
|
||||
return 'bg-red-600 active:bg-red-700';
|
||||
case 'success':
|
||||
return 'bg-green-600 active:bg-green-700';
|
||||
case 'warning':
|
||||
return 'bg-yellow-600 active:bg-yellow-700';
|
||||
default:
|
||||
return 'bg-blue-600 active:bg-blue-700';
|
||||
}
|
||||
};
|
||||
// Get variant styles
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return 'bg-blue-600 active:bg-blue-700';
|
||||
case 'secondary':
|
||||
return 'bg-gray-600 active:bg-gray-700';
|
||||
case 'outline':
|
||||
return 'border border-gray-300 bg-white active:bg-gray-50';
|
||||
case 'ghost':
|
||||
return 'bg-transparent active:bg-gray-100';
|
||||
case 'link':
|
||||
return 'bg-transparent';
|
||||
case 'destructive':
|
||||
return 'bg-red-600 active:bg-red-700';
|
||||
case 'success':
|
||||
return 'bg-green-600 active:bg-green-700';
|
||||
case 'warning':
|
||||
return 'bg-yellow-600 active:bg-yellow-700';
|
||||
default:
|
||||
return 'bg-blue-600 active:bg-blue-700';
|
||||
}
|
||||
};
|
||||
|
||||
// Get size styles
|
||||
const getSizeClasses = () => {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'px-2 py-1';
|
||||
case 'sm':
|
||||
return 'px-3 py-2';
|
||||
case 'md':
|
||||
return 'px-4 py-3';
|
||||
case 'lg':
|
||||
return 'px-6 py-4';
|
||||
case 'xl':
|
||||
return 'px-8 py-5';
|
||||
default:
|
||||
return 'px-4 py-3';
|
||||
}
|
||||
};
|
||||
// Get size styles
|
||||
const getSizeClasses = () => {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'px-2 py-1';
|
||||
case 'sm':
|
||||
return 'px-3 py-2';
|
||||
case 'md':
|
||||
return 'px-4 py-3';
|
||||
case 'lg':
|
||||
return 'px-6 py-4';
|
||||
case 'xl':
|
||||
return 'px-8 py-5';
|
||||
default:
|
||||
return 'px-4 py-3';
|
||||
}
|
||||
};
|
||||
|
||||
// Get text color
|
||||
const getTextColor = () => {
|
||||
if (isDisabled) return 'muted';
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'secondary':
|
||||
case 'destructive':
|
||||
case 'success':
|
||||
case 'warning':
|
||||
return 'white';
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return 'gray';
|
||||
case 'link':
|
||||
return 'primary';
|
||||
default:
|
||||
return 'white';
|
||||
}
|
||||
};
|
||||
// Get text color
|
||||
const getTextColor = () => {
|
||||
if (isDisabled) return 'muted';
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'secondary':
|
||||
case 'destructive':
|
||||
case 'success':
|
||||
case 'warning':
|
||||
return 'white';
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return 'gray';
|
||||
case 'link':
|
||||
return 'primary';
|
||||
default:
|
||||
return 'white';
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon color
|
||||
const getIconColor = () => {
|
||||
if (isDisabled) return '#9CA3AF';
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'secondary':
|
||||
case 'destructive':
|
||||
case 'success':
|
||||
case 'warning':
|
||||
return '#FFFFFF';
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return '#6B7280';
|
||||
case 'link':
|
||||
return '#2563EB';
|
||||
default:
|
||||
return '#FFFFFF';
|
||||
}
|
||||
};
|
||||
// Get icon color
|
||||
const getIconColor = () => {
|
||||
if (isDisabled) return '#9CA3AF';
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'secondary':
|
||||
case 'destructive':
|
||||
case 'success':
|
||||
case 'warning':
|
||||
return '#FFFFFF';
|
||||
case 'outline':
|
||||
case 'ghost':
|
||||
return '#6B7280';
|
||||
case 'link':
|
||||
return '#2563EB';
|
||||
default:
|
||||
return '#FFFFFF';
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon size
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 14;
|
||||
case 'sm':
|
||||
return 16;
|
||||
case 'md':
|
||||
return 18;
|
||||
case 'lg':
|
||||
return 20;
|
||||
case 'xl':
|
||||
return 24;
|
||||
default:
|
||||
return 18;
|
||||
}
|
||||
};
|
||||
// Get icon size
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 14;
|
||||
case 'sm':
|
||||
return 16;
|
||||
case 'md':
|
||||
return 18;
|
||||
case 'lg':
|
||||
return 20;
|
||||
case 'xl':
|
||||
return 24;
|
||||
default:
|
||||
return 18;
|
||||
}
|
||||
};
|
||||
|
||||
// Get text variant
|
||||
const getTextVariant = () => {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
case 'sm':
|
||||
return 'buttonSmall';
|
||||
default:
|
||||
return 'button';
|
||||
}
|
||||
};
|
||||
// Get text variant
|
||||
const getTextVariant = () => {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
case 'sm':
|
||||
return 'buttonSmall';
|
||||
default:
|
||||
return 'button';
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <ActivityIndicator size="small" color={getIconColor()} />;
|
||||
}
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <ActivityIndicator size="small" color={getIconColor()} />;
|
||||
}
|
||||
|
||||
const iconElement = icon ? (
|
||||
<Icon name={icon} size={getIconSize()} color={getIconColor()} />
|
||||
) : null;
|
||||
const iconElement = icon ? (
|
||||
<Icon name={icon} size={getIconSize()} color={getIconColor()} />
|
||||
) : null;
|
||||
|
||||
const textElement = children ? (
|
||||
<Text variant={getTextVariant()} color={getTextColor()} align="center">
|
||||
{children}
|
||||
</Text>
|
||||
) : null;
|
||||
const textElement = children ? (
|
||||
<Text variant={getTextVariant()} color={getTextColor()} align="center">
|
||||
{children}
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
if (!icon && !children) {
|
||||
return null;
|
||||
}
|
||||
if (!icon && !children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (icon && !children) {
|
||||
return iconElement;
|
||||
}
|
||||
if (icon && !children) {
|
||||
return iconElement;
|
||||
}
|
||||
|
||||
if (!icon && children) {
|
||||
return textElement;
|
||||
}
|
||||
if (!icon && children) {
|
||||
return textElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-2">
|
||||
{iconPosition === 'left' && iconElement}
|
||||
{textElement}
|
||||
{iconPosition === 'right' && iconElement}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<View className="flex-row items-center gap-2">
|
||||
{iconPosition === 'left' && iconElement}
|
||||
{textElement}
|
||||
{iconPosition === 'right' && iconElement}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const buttonClasses = [
|
||||
'rounded-lg items-center justify-center',
|
||||
getSizeClasses(),
|
||||
getVariantClasses(),
|
||||
fullWidth ? 'w-full' : '',
|
||||
isDisabled ? 'opacity-50' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const buttonClasses = [
|
||||
'rounded-lg items-center justify-center',
|
||||
getSizeClasses(),
|
||||
getVariantClasses(),
|
||||
fullWidth ? 'w-full' : '',
|
||||
isDisabled ? 'opacity-50' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<Pressable className={buttonClasses} disabled={isDisabled} {...props}>
|
||||
{renderContent()}
|
||||
</Pressable>
|
||||
);
|
||||
return (
|
||||
<Pressable className={buttonClasses} disabled={isDisabled} {...props}>
|
||||
{renderContent()}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,156 +1,159 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface ContextMenuAction {
|
||||
title: string;
|
||||
systemIcon?: string;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
systemIcon?: string;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
actions: ContextMenuAction[];
|
||||
onPress: (index: number) => void;
|
||||
children: React.ReactElement;
|
||||
actions: ContextMenuAction[];
|
||||
onPress: (index: number) => void;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export function ContextMenu({ actions, onPress, children }: ContextMenuProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const childRef = useRef<View>(null);
|
||||
const { colors } = useTheme();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const childRef = useRef<View>(null);
|
||||
const { colors } = useTheme();
|
||||
|
||||
const handleLongPress = () => {
|
||||
childRef.current?.measure((x, y, width, height, pageX, pageY) => {
|
||||
const screenHeight = Dimensions.get('window').height;
|
||||
const menuHeight = actions.length * 50 + 20; // Approximate menu height
|
||||
const handleLongPress = () => {
|
||||
childRef.current?.measure((x, y, width, height, pageX, pageY) => {
|
||||
const screenHeight = Dimensions.get('window').height;
|
||||
const menuHeight = actions.length * 50 + 20; // Approximate menu height
|
||||
|
||||
// Position menu above or below the pressed item based on available space
|
||||
const posY = pageY + height + menuHeight > screenHeight ? pageY - menuHeight : pageY + height;
|
||||
// Position menu above or below the pressed item based on available space
|
||||
const posY = pageY + height + menuHeight > screenHeight ? pageY - menuHeight : pageY + height;
|
||||
|
||||
setMenuPosition({ x: pageX, y: posY });
|
||||
setVisible(true);
|
||||
});
|
||||
};
|
||||
setMenuPosition({ x: pageX, y: posY });
|
||||
setVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleActionPress = (index: number) => {
|
||||
setVisible(false);
|
||||
// Small delay to allow modal to close before action
|
||||
setTimeout(() => onPress(index), 100);
|
||||
};
|
||||
const handleActionPress = (index: number) => {
|
||||
setVisible(false);
|
||||
// Small delay to allow modal to close before action
|
||||
setTimeout(() => onPress(index), 100);
|
||||
};
|
||||
|
||||
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
'doc.text': 'document-text-outline',
|
||||
'play.circle': 'play-circle-outline',
|
||||
'square.and.arrow.up': 'share-outline',
|
||||
tag: 'pricetag-outline',
|
||||
trash: 'trash-outline',
|
||||
};
|
||||
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
|
||||
'doc.text': 'document-text-outline',
|
||||
'play.circle': 'play-circle-outline',
|
||||
'square.and.arrow.up': 'share-outline',
|
||||
tag: 'pricetag-outline',
|
||||
trash: 'trash-outline',
|
||||
};
|
||||
|
||||
const renderAction = ({ item, index }: { item: ContextMenuAction; index: number }) => {
|
||||
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
|
||||
const isDisabled = item.disabled;
|
||||
const isDestructive = item.destructive;
|
||||
const renderAction = ({ item, index }: { item: ContextMenuAction; index: number }) => {
|
||||
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
|
||||
const isDisabled = item.disabled;
|
||||
const isDestructive = item.destructive;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !isDisabled && handleActionPress(index)}
|
||||
disabled={isDisabled}
|
||||
className={`flex-row items-center px-4 py-3 ${
|
||||
index < actions.length - 1 ? `border-b ${colors.border}` : ''
|
||||
}`}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
})}>
|
||||
{iconName && (
|
||||
<Ionicons
|
||||
name={iconName}
|
||||
size={20}
|
||||
color={
|
||||
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
|
||||
}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
)}
|
||||
<Text className={`text-base ${isDestructive ? 'text-red-500' : colors.text}`}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !isDisabled && handleActionPress(index)}
|
||||
disabled={isDisabled}
|
||||
className={`flex-row items-center px-4 py-3 ${
|
||||
index < actions.length - 1 ? `border-b ${colors.border}` : ''
|
||||
}`}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
{iconName && (
|
||||
<Ionicons
|
||||
name={iconName}
|
||||
size={20}
|
||||
color={
|
||||
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
|
||||
}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
)}
|
||||
<Text className={`text-base ${isDestructive ? 'text-red-500' : colors.text}`}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<View ref={childRef} collapsable={false}>
|
||||
{React.cloneElement(children, {
|
||||
onLongPress: handleLongPress,
|
||||
delayLongPress: 500,
|
||||
} as any)}
|
||||
</View>
|
||||
return (
|
||||
<>
|
||||
<View ref={childRef} collapsable={false}>
|
||||
{React.cloneElement(children, {
|
||||
onLongPress: handleLongPress,
|
||||
delayLongPress: 500,
|
||||
} as any)}
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setVisible(false)}>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
|
||||
<View style={[styles.backdrop, { backgroundColor: 'rgba(0, 0, 0, 0.3)' }]} />
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setVisible(false)}
|
||||
>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
|
||||
<View style={[styles.backdrop, { backgroundColor: 'rgba(0, 0, 0, 0.3)' }]} />
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.menu,
|
||||
{
|
||||
top: menuPosition.y,
|
||||
left: 20,
|
||||
right: 20,
|
||||
maxWidth: 300,
|
||||
alignSelf: 'center',
|
||||
backgroundColor: colors.text.includes('white') ? '#1f2937' : '#ffffff',
|
||||
},
|
||||
]}
|
||||
className={`rounded-lg shadow-lg ${colors.surface}`}>
|
||||
<FlatList
|
||||
data={actions}
|
||||
renderItem={renderAction}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
<View
|
||||
style={[
|
||||
styles.menu,
|
||||
{
|
||||
top: menuPosition.y,
|
||||
left: 20,
|
||||
right: 20,
|
||||
maxWidth: 300,
|
||||
alignSelf: 'center',
|
||||
backgroundColor: colors.text.includes('white') ? '#1f2937' : '#ffffff',
|
||||
},
|
||||
]}
|
||||
className={`rounded-lg shadow-lg ${colors.surface}`}
|
||||
>
|
||||
<FlatList
|
||||
data={actions}
|
||||
renderItem={renderAction}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
menu: {
|
||||
position: 'absolute',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
// @ts-ignore - React Native Web supports boxShadow
|
||||
boxShadow: '0px 2px 10px rgba(0, 0, 0, 0.25)',
|
||||
},
|
||||
android: {
|
||||
elevation: 8,
|
||||
},
|
||||
}),
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
menu: {
|
||||
position: 'absolute',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
...Platform.select({
|
||||
ios: {
|
||||
// @ts-ignore - React Native Web supports boxShadow
|
||||
boxShadow: '0px 2px 10px rgba(0, 0, 0, 0.25)',
|
||||
},
|
||||
android: {
|
||||
elevation: 8,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,40 +3,41 @@ import { Pressable, Text, ActivityIndicator, ViewStyle } from 'react-native';
|
|||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface FloatingActionButtonProps {
|
||||
onPress: () => void;
|
||||
icon: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
style?: ViewStyle;
|
||||
onPress: () => void;
|
||||
icon: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
export function FloatingActionButton({
|
||||
onPress,
|
||||
icon,
|
||||
label,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
style,
|
||||
onPress,
|
||||
icon,
|
||||
label,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
style,
|
||||
}: FloatingActionButtonProps) {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
style={style}
|
||||
className={`flex-row items-center rounded-full px-4 py-3 shadow-lg ${
|
||||
disabled || loading ? 'bg-gray-400' : colors.primary
|
||||
}`}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<>
|
||||
<Text className="mr-2 text-lg text-white">{icon}</Text>
|
||||
<Text className="font-medium text-white">{label}</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
style={style}
|
||||
className={`flex-row items-center rounded-full px-4 py-3 shadow-lg ${
|
||||
disabled || loading ? 'bg-gray-400' : colors.primary
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<>
|
||||
<Text className="mr-2 text-lg text-white">{icon}</Text>
|
||||
<Text className="font-medium text-white">{label}</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,83 +7,86 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string;
|
||||
showBackButton?: boolean;
|
||||
rightComponent?: React.ReactNode;
|
||||
onBackPress?: () => void;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
title?: string;
|
||||
showBackButton?: boolean;
|
||||
rightComponent?: React.ReactNode;
|
||||
onBackPress?: () => void;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
title,
|
||||
showBackButton = true,
|
||||
rightComponent,
|
||||
onBackPress,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
title,
|
||||
showBackButton = true,
|
||||
rightComponent,
|
||||
onBackPress,
|
||||
backgroundColor,
|
||||
textColor,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isDark, colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isDark, colors } = useTheme();
|
||||
|
||||
const handleBackPress = () => {
|
||||
if (onBackPress) {
|
||||
onBackPress();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
const handleBackPress = () => {
|
||||
if (onBackPress) {
|
||||
onBackPress();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
// Use theme colors if not explicitly provided
|
||||
const headerBackgroundColor = backgroundColor || (isDark ? colors.tabBarBackground : '#ffffff');
|
||||
const headerTextColor = textColor || (isDark ? '#ffffff' : '#000000');
|
||||
const borderColor = isDark ? colors.tabBarBorder : '#e5e7eb';
|
||||
// Use theme colors if not explicitly provided
|
||||
const headerBackgroundColor = backgroundColor || (isDark ? colors.tabBarBackground : '#ffffff');
|
||||
const headerTextColor = textColor || (isDark ? '#ffffff' : '#000000');
|
||||
const borderColor = isDark ? colors.tabBarBorder : '#e5e7eb';
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: headerBackgroundColor,
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: borderColor,
|
||||
}}>
|
||||
<StatusBar
|
||||
barStyle={isDark ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={headerBackgroundColor}
|
||||
/>
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: headerBackgroundColor,
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: borderColor,
|
||||
}}
|
||||
>
|
||||
<StatusBar
|
||||
barStyle={isDark ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={headerBackgroundColor}
|
||||
/>
|
||||
|
||||
<View className="min-h-[44px] flex-row items-center justify-between">
|
||||
{/* Left side - Back button */}
|
||||
<View className="flex-1 flex-row items-center">
|
||||
{showBackButton && (
|
||||
<Pressable
|
||||
onPress={handleBackPress}
|
||||
className="-ml-2 mr-3 rounded-full p-2"
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Icon name="arrow-back" size={24} color={headerTextColor} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<View className="min-h-[44px] flex-row items-center justify-between">
|
||||
{/* Left side - Back button */}
|
||||
<View className="flex-1 flex-row items-center">
|
||||
{showBackButton && (
|
||||
<Pressable
|
||||
onPress={handleBackPress}
|
||||
className="-ml-2 mr-3 rounded-full p-2"
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Icon name="arrow-back" size={24} color={headerTextColor} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Center - Title */}
|
||||
<View className="flex-2 items-center">
|
||||
{title && (
|
||||
<Text
|
||||
variant="h4"
|
||||
color={headerTextColor === '#000000' ? 'black' : 'white'}
|
||||
className="text-center font-semibold"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{/* Center - Title */}
|
||||
<View className="flex-2 items-center">
|
||||
{title && (
|
||||
<Text
|
||||
variant="h4"
|
||||
color={headerTextColor === '#000000' ? 'black' : 'white'}
|
||||
className="text-center font-semibold"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right side - Custom component */}
|
||||
<View className="flex-1 flex-row items-center justify-end">{rightComponent}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
{/* Right side - Custom component */}
|
||||
<View className="flex-1 flex-row items-center justify-end">{rightComponent}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,126 +3,126 @@ import { View } from 'react-native';
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export type IconName =
|
||||
| 'add'
|
||||
| 'delete'
|
||||
| 'edit'
|
||||
| 'save'
|
||||
| 'close'
|
||||
| 'back'
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'stop'
|
||||
| 'refresh'
|
||||
| 'settings'
|
||||
| 'logout'
|
||||
| 'eye'
|
||||
| 'eye-off'
|
||||
| 'heart'
|
||||
| 'heart-outline'
|
||||
| 'tag'
|
||||
| 'filter'
|
||||
| 'search'
|
||||
| 'download'
|
||||
| 'share'
|
||||
| 'volume-high'
|
||||
| 'volume-low'
|
||||
| 'volume-mute'
|
||||
| 'fast-forward'
|
||||
| 'rewind'
|
||||
| 'skip-forward'
|
||||
| 'skip-backward'
|
||||
| 'checkmark'
|
||||
| 'close-circle'
|
||||
| 'alert-circle'
|
||||
| 'information-circle'
|
||||
| 'chevron-down'
|
||||
| 'chevron-up'
|
||||
| 'chevron-left'
|
||||
| 'chevron-right'
|
||||
| 'arrow-back'
|
||||
| 'arrow-forward'
|
||||
| 'home'
|
||||
| 'library'
|
||||
| 'person'
|
||||
| 'menu'
|
||||
| 'more-horizontal'
|
||||
| 'more-vertical'
|
||||
| 'replay-15'
|
||||
| 'forward-15'
|
||||
| 'play-circle'
|
||||
| 'pause-circle'
|
||||
| 'mic-circle';
|
||||
| 'add'
|
||||
| 'delete'
|
||||
| 'edit'
|
||||
| 'save'
|
||||
| 'close'
|
||||
| 'back'
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'stop'
|
||||
| 'refresh'
|
||||
| 'settings'
|
||||
| 'logout'
|
||||
| 'eye'
|
||||
| 'eye-off'
|
||||
| 'heart'
|
||||
| 'heart-outline'
|
||||
| 'tag'
|
||||
| 'filter'
|
||||
| 'search'
|
||||
| 'download'
|
||||
| 'share'
|
||||
| 'volume-high'
|
||||
| 'volume-low'
|
||||
| 'volume-mute'
|
||||
| 'fast-forward'
|
||||
| 'rewind'
|
||||
| 'skip-forward'
|
||||
| 'skip-backward'
|
||||
| 'checkmark'
|
||||
| 'close-circle'
|
||||
| 'alert-circle'
|
||||
| 'information-circle'
|
||||
| 'chevron-down'
|
||||
| 'chevron-up'
|
||||
| 'chevron-left'
|
||||
| 'chevron-right'
|
||||
| 'arrow-back'
|
||||
| 'arrow-forward'
|
||||
| 'home'
|
||||
| 'library'
|
||||
| 'person'
|
||||
| 'menu'
|
||||
| 'more-horizontal'
|
||||
| 'more-vertical'
|
||||
| 'replay-15'
|
||||
| 'forward-15'
|
||||
| 'play-circle'
|
||||
| 'pause-circle'
|
||||
| 'mic-circle';
|
||||
|
||||
interface IconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
name: IconName;
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const iconMapping: Record<IconName, keyof typeof Ionicons.glyphMap> = {
|
||||
add: 'add',
|
||||
delete: 'trash',
|
||||
edit: 'pencil',
|
||||
save: 'save',
|
||||
close: 'close',
|
||||
back: 'arrow-back',
|
||||
play: 'play',
|
||||
pause: 'pause',
|
||||
stop: 'stop',
|
||||
refresh: 'refresh',
|
||||
settings: 'settings',
|
||||
logout: 'log-out',
|
||||
eye: 'eye',
|
||||
'eye-off': 'eye-off',
|
||||
heart: 'heart',
|
||||
'heart-outline': 'heart-outline',
|
||||
tag: 'pricetag',
|
||||
filter: 'filter',
|
||||
search: 'search',
|
||||
download: 'download',
|
||||
share: 'share',
|
||||
'volume-high': 'volume-high',
|
||||
'volume-low': 'volume-low',
|
||||
'volume-mute': 'volume-mute',
|
||||
'fast-forward': 'play-forward',
|
||||
rewind: 'play-back',
|
||||
'skip-forward': 'play-skip-forward',
|
||||
'skip-backward': 'play-skip-back',
|
||||
checkmark: 'checkmark',
|
||||
'close-circle': 'close-circle',
|
||||
'alert-circle': 'alert-circle',
|
||||
'information-circle': 'information-circle',
|
||||
'chevron-down': 'chevron-down',
|
||||
'chevron-up': 'chevron-up',
|
||||
'chevron-left': 'chevron-back',
|
||||
'chevron-right': 'chevron-forward',
|
||||
'arrow-back': 'arrow-back',
|
||||
'arrow-forward': 'arrow-forward',
|
||||
home: 'home',
|
||||
library: 'library',
|
||||
person: 'person',
|
||||
menu: 'menu',
|
||||
'more-horizontal': 'ellipsis-horizontal',
|
||||
'more-vertical': 'ellipsis-vertical',
|
||||
'replay-15': 'refresh-circle',
|
||||
'forward-15': 'add-circle',
|
||||
'play-circle': 'play-circle',
|
||||
'pause-circle': 'pause-circle',
|
||||
'mic-circle': 'mic-circle',
|
||||
add: 'add',
|
||||
delete: 'trash',
|
||||
edit: 'pencil',
|
||||
save: 'save',
|
||||
close: 'close',
|
||||
back: 'arrow-back',
|
||||
play: 'play',
|
||||
pause: 'pause',
|
||||
stop: 'stop',
|
||||
refresh: 'refresh',
|
||||
settings: 'settings',
|
||||
logout: 'log-out',
|
||||
eye: 'eye',
|
||||
'eye-off': 'eye-off',
|
||||
heart: 'heart',
|
||||
'heart-outline': 'heart-outline',
|
||||
tag: 'pricetag',
|
||||
filter: 'filter',
|
||||
search: 'search',
|
||||
download: 'download',
|
||||
share: 'share',
|
||||
'volume-high': 'volume-high',
|
||||
'volume-low': 'volume-low',
|
||||
'volume-mute': 'volume-mute',
|
||||
'fast-forward': 'play-forward',
|
||||
rewind: 'play-back',
|
||||
'skip-forward': 'play-skip-forward',
|
||||
'skip-backward': 'play-skip-back',
|
||||
checkmark: 'checkmark',
|
||||
'close-circle': 'close-circle',
|
||||
'alert-circle': 'alert-circle',
|
||||
'information-circle': 'information-circle',
|
||||
'chevron-down': 'chevron-down',
|
||||
'chevron-up': 'chevron-up',
|
||||
'chevron-left': 'chevron-back',
|
||||
'chevron-right': 'chevron-forward',
|
||||
'arrow-back': 'arrow-back',
|
||||
'arrow-forward': 'arrow-forward',
|
||||
home: 'home',
|
||||
library: 'library',
|
||||
person: 'person',
|
||||
menu: 'menu',
|
||||
'more-horizontal': 'ellipsis-horizontal',
|
||||
'more-vertical': 'ellipsis-vertical',
|
||||
'replay-15': 'refresh-circle',
|
||||
'forward-15': 'add-circle',
|
||||
'play-circle': 'play-circle',
|
||||
'pause-circle': 'pause-circle',
|
||||
'mic-circle': 'mic-circle',
|
||||
};
|
||||
|
||||
export const Icon: React.FC<IconProps> = ({ name, size = 24, color = '#000000', className }) => {
|
||||
const ionIconName = iconMapping[name];
|
||||
const ionIconName = iconMapping[name];
|
||||
|
||||
if (!ionIconName) {
|
||||
console.warn(`Icon "${name}" not found in iconMapping`);
|
||||
return null;
|
||||
}
|
||||
if (!ionIconName) {
|
||||
console.warn(`Icon "${name}" not found in iconMapping`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={className}>
|
||||
<Ionicons name={ionIconName} size={size} color={color} />
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className={className}>
|
||||
<Ionicons name={ionIconName} size={size} color={color} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,83 +8,84 @@ import { useTheme } from '~/hooks/useTheme';
|
|||
import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
|
||||
|
||||
interface MinimalAudioPlayerProps {
|
||||
text: TextType;
|
||||
text: TextType;
|
||||
}
|
||||
|
||||
export const MinimalAudioPlayer: React.FC<MinimalAudioPlayerProps> = ({ text }) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { currentTextId } = useStore();
|
||||
const { colors } = useTheme();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const { currentTextId } = useStore();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const { audioState, generateAudio, playAudio, pauseAudio, resumeAudio, stopAudio } = useAudio();
|
||||
const { audioState, generateAudio, playAudio, pauseAudio, resumeAudio, stopAudio } = useAudio();
|
||||
|
||||
// Check if this text is currently playing
|
||||
const isCurrentText = currentTextId === text.id;
|
||||
const isPlaying = isCurrentText && audioState.isPlaying;
|
||||
const isLoading = isCurrentText && audioState.isLoading;
|
||||
// Check if this text is currently playing
|
||||
const isCurrentText = currentTextId === text.id;
|
||||
const isPlaying = isCurrentText && audioState.isPlaying;
|
||||
const isLoading = isCurrentText && audioState.isLoading;
|
||||
|
||||
// Get audio version
|
||||
const migratedData = migrateAudioData(text.data);
|
||||
const currentVersion = getCurrentAudioVersion(migratedData);
|
||||
const hasAudio = currentVersion && currentVersion.chunks.length > 0;
|
||||
// Get audio version
|
||||
const migratedData = migrateAudioData(text.data);
|
||||
const currentVersion = getCurrentAudioVersion(migratedData);
|
||||
const hasAudio = currentVersion && currentVersion.chunks.length > 0;
|
||||
|
||||
// Stop audio when component unmounts or text changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isCurrentText) {
|
||||
stopAudio();
|
||||
}
|
||||
};
|
||||
}, [isCurrentText, stopAudio]);
|
||||
// Stop audio when component unmounts or text changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isCurrentText) {
|
||||
stopAudio();
|
||||
}
|
||||
};
|
||||
}, [isCurrentText, stopAudio]);
|
||||
|
||||
const handlePlayPause = async () => {
|
||||
if (!hasAudio) {
|
||||
// Generate audio if not available
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
const { settings } = useStore.getState();
|
||||
await generateAudio(text.id, text.content, settings.voice, settings.speed, text);
|
||||
} catch (error) {
|
||||
console.error('Error generating audio:', error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const handlePlayPause = async () => {
|
||||
if (!hasAudio) {
|
||||
// Generate audio if not available
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
const { settings } = useStore.getState();
|
||||
await generateAudio(text.id, text.content, settings.voice, settings.speed, text);
|
||||
} catch (error) {
|
||||
console.error('Error generating audio:', error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPlaying) {
|
||||
await pauseAudio();
|
||||
} else if (isCurrentText && audioState.sound) {
|
||||
await resumeAudio();
|
||||
} else {
|
||||
// Stop any other playing audio and start this one
|
||||
if (currentTextId && currentTextId !== text.id) {
|
||||
await stopAudio();
|
||||
}
|
||||
await playAudio(text.id, currentVersion.chunks, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (isPlaying) {
|
||||
await pauseAudio();
|
||||
} else if (isCurrentText && audioState.sound) {
|
||||
await resumeAudio();
|
||||
} else {
|
||||
// Stop any other playing audio and start this one
|
||||
if (currentTextId && currentTextId !== text.id) {
|
||||
await stopAudio();
|
||||
}
|
||||
await playAudio(text.id, currentVersion.chunks, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePlayPause}
|
||||
disabled={isLoading || isGenerating}
|
||||
className={`rounded-full p-2 ${
|
||||
hasAudio ? colors.surfaceSecondary : colors.surface
|
||||
} active:opacity-70`}>
|
||||
{isLoading || isGenerating ? (
|
||||
<ActivityIndicator size="small" color={colors.tabBarInactive} />
|
||||
) : (
|
||||
<Icon
|
||||
name={hasAudio ? (isPlaying ? 'pause-circle' : 'play-circle') : 'mic-circle'}
|
||||
size={28}
|
||||
color={hasAudio ? colors.tabBarActive : colors.tabBarInactive}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePlayPause}
|
||||
disabled={isLoading || isGenerating}
|
||||
className={`rounded-full p-2 ${
|
||||
hasAudio ? colors.surfaceSecondary : colors.surface
|
||||
} active:opacity-70`}
|
||||
>
|
||||
{isLoading || isGenerating ? (
|
||||
<ActivityIndicator size="small" color={colors.tabBarInactive} />
|
||||
) : (
|
||||
<Icon
|
||||
name={hasAudio ? (isPlaying ? 'pause-circle' : 'play-circle') : 'mic-circle'}
|
||||
size={28}
|
||||
color={hasAudio ? colors.tabBarActive : colors.tabBarInactive}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,23 +3,23 @@ import { Text, View } from 'react-native';
|
|||
import { EditScreenInfo } from './EditScreenInfo';
|
||||
|
||||
type ScreenContentProps = {
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||
title: `text-xl font-bold`,
|
||||
container: `items-center flex-1 justify-center`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||
title: `text-xl font-bold`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ import FontAwesome from '@expo/vector-icons/FontAwesome';
|
|||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export const TabBarIcon = (props: {
|
||||
name: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
color: string;
|
||||
name: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
color: string;
|
||||
}) => {
|
||||
return <FontAwesome size={28} style={styles.tabBarIcon} {...props} />;
|
||||
return <FontAwesome size={28} style={styles.tabBarIcon} {...props} />;
|
||||
};
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
tabBarIcon: {
|
||||
marginBottom: -3,
|
||||
},
|
||||
tabBarIcon: {
|
||||
marginBottom: -3,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,49 +5,51 @@ import { useStore } from '~/store/store';
|
|||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export const TagFilter: React.FC = () => {
|
||||
const { getAllTags } = useTexts();
|
||||
const { selectedTags, toggleTag, clearTags } = useStore();
|
||||
const { colors } = useTheme();
|
||||
const { getAllTags } = useTexts();
|
||||
const { selectedTags, toggleTag, clearTags } = useStore();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const allTags = getAllTags();
|
||||
const allTags = getAllTags();
|
||||
|
||||
if (allTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (allTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`border-b ${colors.border} ${colors.surface} px-4 py-2`}>
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className={`text-sm font-medium ${colors.textSecondary}`}>Tags filtern:</Text>
|
||||
{selectedTags.length > 0 && (
|
||||
<Pressable onPress={clearTags}>
|
||||
<Text className="text-sm text-blue-600">Alle entfernen</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
return (
|
||||
<View className={`border-b ${colors.border} ${colors.surface} px-4 py-2`}>
|
||||
<View className="mb-2 flex-row items-center justify-between">
|
||||
<Text className={`text-sm font-medium ${colors.textSecondary}`}>Tags filtern:</Text>
|
||||
{selectedTags.length > 0 && (
|
||||
<Pressable onPress={clearTags}>
|
||||
<Text className="text-sm text-blue-600">Alle entfernen</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingRight: 16 }}>
|
||||
{allTags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag);
|
||||
return (
|
||||
<Pressable
|
||||
key={tag}
|
||||
onPress={() => toggleTag(tag)}
|
||||
className={`mr-2 rounded-full border px-3 py-1 ${
|
||||
isSelected
|
||||
? `border-blue-500 ${colors.primaryLight}`
|
||||
: `${colors.borderSecondary} ${colors.surfaceSecondary}`
|
||||
}`}>
|
||||
<Text className={`text-sm ${isSelected ? 'text-blue-800' : colors.textSecondary}`}>
|
||||
{tag}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingRight: 16 }}
|
||||
>
|
||||
{allTags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag);
|
||||
return (
|
||||
<Pressable
|
||||
key={tag}
|
||||
onPress={() => toggleTag(tag)}
|
||||
className={`mr-2 rounded-full border px-3 py-1 ${
|
||||
isSelected
|
||||
? `border-blue-500 ${colors.primaryLight}`
|
||||
: `${colors.borderSecondary} ${colors.surfaceSecondary}`
|
||||
}`}
|
||||
>
|
||||
<Text className={`text-sm ${isSelected ? 'text-blue-800' : colors.textSecondary}`}>
|
||||
{tag}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,156 +3,156 @@ import { Text as RNText, TextProps as RNTextProps } from 'react-native';
|
|||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export type TextVariant =
|
||||
| 'h1'
|
||||
| 'h2'
|
||||
| 'h3'
|
||||
| 'h4'
|
||||
| 'h5'
|
||||
| 'h6'
|
||||
| 'body'
|
||||
| 'bodyLarge'
|
||||
| 'bodySmall'
|
||||
| 'caption'
|
||||
| 'label'
|
||||
| 'labelLarge'
|
||||
| 'labelSmall'
|
||||
| 'button'
|
||||
| 'buttonSmall'
|
||||
| 'overline'
|
||||
| 'subtitle1'
|
||||
| 'subtitle2';
|
||||
| 'h1'
|
||||
| 'h2'
|
||||
| 'h3'
|
||||
| 'h4'
|
||||
| 'h5'
|
||||
| 'h6'
|
||||
| 'body'
|
||||
| 'bodyLarge'
|
||||
| 'bodySmall'
|
||||
| 'caption'
|
||||
| 'label'
|
||||
| 'labelLarge'
|
||||
| 'labelSmall'
|
||||
| 'button'
|
||||
| 'buttonSmall'
|
||||
| 'overline'
|
||||
| 'subtitle1'
|
||||
| 'subtitle2';
|
||||
|
||||
export type TextColor =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'tertiary'
|
||||
| 'accent'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'info'
|
||||
| 'white'
|
||||
| 'black'
|
||||
| 'gray'
|
||||
| 'muted'
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'purple'
|
||||
| 'pink'
|
||||
| 'indigo'
|
||||
| 'cyan'
|
||||
| 'orange'
|
||||
| 'inherit';
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'tertiary'
|
||||
| 'accent'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'success'
|
||||
| 'info'
|
||||
| 'white'
|
||||
| 'black'
|
||||
| 'gray'
|
||||
| 'muted'
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'purple'
|
||||
| 'pink'
|
||||
| 'indigo'
|
||||
| 'cyan'
|
||||
| 'orange'
|
||||
| 'inherit';
|
||||
|
||||
interface TextComponentProps extends RNTextProps {
|
||||
variant?: TextVariant;
|
||||
color?: TextColor;
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
align?: 'left' | 'center' | 'right' | 'justify';
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
variant?: TextVariant;
|
||||
color?: TextColor;
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
align?: 'left' | 'center' | 'right' | 'justify';
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<TextVariant, string> = {
|
||||
h1: 'text-4xl font-bold',
|
||||
h2: 'text-3xl font-bold',
|
||||
h3: 'text-2xl font-bold',
|
||||
h4: 'text-xl font-bold',
|
||||
h5: 'text-lg font-bold',
|
||||
h6: 'text-base font-bold',
|
||||
body: 'text-base',
|
||||
bodyLarge: 'text-lg',
|
||||
bodySmall: 'text-sm',
|
||||
caption: 'text-xs',
|
||||
label: 'text-sm font-medium',
|
||||
labelLarge: 'text-base font-medium',
|
||||
labelSmall: 'text-xs font-medium',
|
||||
button: 'text-base font-semibold',
|
||||
buttonSmall: 'text-sm font-semibold',
|
||||
overline: 'text-xs font-medium uppercase tracking-wide',
|
||||
subtitle1: 'text-base font-medium',
|
||||
subtitle2: 'text-sm font-medium',
|
||||
h1: 'text-4xl font-bold',
|
||||
h2: 'text-3xl font-bold',
|
||||
h3: 'text-2xl font-bold',
|
||||
h4: 'text-xl font-bold',
|
||||
h5: 'text-lg font-bold',
|
||||
h6: 'text-base font-bold',
|
||||
body: 'text-base',
|
||||
bodyLarge: 'text-lg',
|
||||
bodySmall: 'text-sm',
|
||||
caption: 'text-xs',
|
||||
label: 'text-sm font-medium',
|
||||
labelLarge: 'text-base font-medium',
|
||||
labelSmall: 'text-xs font-medium',
|
||||
button: 'text-base font-semibold',
|
||||
buttonSmall: 'text-sm font-semibold',
|
||||
overline: 'text-xs font-medium uppercase tracking-wide',
|
||||
subtitle1: 'text-base font-medium',
|
||||
subtitle2: 'text-sm font-medium',
|
||||
};
|
||||
|
||||
const colorStyles: Record<TextColor, string> = {
|
||||
primary: 'text-blue-600',
|
||||
secondary: 'text-gray-600',
|
||||
accent: 'text-purple-600',
|
||||
error: 'text-red-600',
|
||||
warning: 'text-yellow-600',
|
||||
success: 'text-green-600',
|
||||
info: 'text-blue-500',
|
||||
white: 'text-white',
|
||||
black: 'text-black',
|
||||
gray: 'text-gray-500',
|
||||
muted: 'text-gray-400',
|
||||
red: 'text-red-600',
|
||||
blue: 'text-blue-600',
|
||||
green: 'text-green-600',
|
||||
yellow: 'text-yellow-600',
|
||||
purple: 'text-purple-600',
|
||||
pink: 'text-pink-600',
|
||||
indigo: 'text-indigo-600',
|
||||
cyan: 'text-cyan-600',
|
||||
orange: 'text-orange-600',
|
||||
primary: 'text-blue-600',
|
||||
secondary: 'text-gray-600',
|
||||
accent: 'text-purple-600',
|
||||
error: 'text-red-600',
|
||||
warning: 'text-yellow-600',
|
||||
success: 'text-green-600',
|
||||
info: 'text-blue-500',
|
||||
white: 'text-white',
|
||||
black: 'text-black',
|
||||
gray: 'text-gray-500',
|
||||
muted: 'text-gray-400',
|
||||
red: 'text-red-600',
|
||||
blue: 'text-blue-600',
|
||||
green: 'text-green-600',
|
||||
yellow: 'text-yellow-600',
|
||||
purple: 'text-purple-600',
|
||||
pink: 'text-pink-600',
|
||||
indigo: 'text-indigo-600',
|
||||
cyan: 'text-cyan-600',
|
||||
orange: 'text-orange-600',
|
||||
};
|
||||
|
||||
const weightStyles: Record<string, string> = {
|
||||
light: 'font-light',
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold',
|
||||
light: 'font-light',
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold',
|
||||
};
|
||||
|
||||
const alignStyles: Record<string, string> = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
justify: 'text-justify',
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
justify: 'text-justify',
|
||||
};
|
||||
|
||||
export const Text: React.FC<TextComponentProps> = ({
|
||||
variant = 'body',
|
||||
color = 'inherit',
|
||||
weight,
|
||||
align,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
variant = 'body',
|
||||
color = 'inherit',
|
||||
weight,
|
||||
align,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
// Map semantic colors to theme colors
|
||||
const getThemeColor = (textColor: TextColor): string => {
|
||||
switch (textColor) {
|
||||
case 'inherit':
|
||||
case 'primary':
|
||||
return colors.text;
|
||||
case 'secondary':
|
||||
return colors.textSecondary;
|
||||
case 'tertiary':
|
||||
case 'muted':
|
||||
return colors.textTertiary;
|
||||
default:
|
||||
return colorStyles[textColor] || colors.text;
|
||||
}
|
||||
};
|
||||
// Map semantic colors to theme colors
|
||||
const getThemeColor = (textColor: TextColor): string => {
|
||||
switch (textColor) {
|
||||
case 'inherit':
|
||||
case 'primary':
|
||||
return colors.text;
|
||||
case 'secondary':
|
||||
return colors.textSecondary;
|
||||
case 'tertiary':
|
||||
case 'muted':
|
||||
return colors.textTertiary;
|
||||
default:
|
||||
return colorStyles[textColor] || colors.text;
|
||||
}
|
||||
};
|
||||
|
||||
const variantClass = variantStyles[variant];
|
||||
const colorClass = getThemeColor(color);
|
||||
const weightClass = weight ? weightStyles[weight] : '';
|
||||
const alignClass = align ? alignStyles[align] : '';
|
||||
const variantClass = variantStyles[variant];
|
||||
const colorClass = getThemeColor(color);
|
||||
const weightClass = weight ? weightStyles[weight] : '';
|
||||
const alignClass = align ? alignStyles[align] : '';
|
||||
|
||||
const combinedClassName = [variantClass, colorClass, weightClass, alignClass, className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const combinedClassName = [variantClass, colorClass, weightClass, alignClass, className]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<RNText className={combinedClassName} {...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
return (
|
||||
<RNText className={combinedClassName} {...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,87 +7,89 @@ import { Text as TextType } from '~/types/database';
|
|||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface TextListItemProps {
|
||||
item: TextType;
|
||||
onShare: (text: TextType) => void;
|
||||
onDelete: (textId: string, title: string) => void;
|
||||
formatDate: (dateString: string) => string;
|
||||
getAudioDuration: (item: TextType) => string | null;
|
||||
item: TextType;
|
||||
onShare: (text: TextType) => void;
|
||||
onDelete: (textId: string, title: string) => void;
|
||||
formatDate: (dateString: string) => string;
|
||||
getAudioDuration: (item: TextType) => string | null;
|
||||
}
|
||||
|
||||
export const TextListItem: React.FC<TextListItemProps> = ({
|
||||
item,
|
||||
onShare,
|
||||
onDelete,
|
||||
formatDate,
|
||||
getAudioDuration,
|
||||
item,
|
||||
onShare,
|
||||
onDelete,
|
||||
formatDate,
|
||||
getAudioDuration,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const handleMenuSelect = (index: number) => {
|
||||
switch (index) {
|
||||
case 0: // Öffnen
|
||||
router.push(`/text/${item.id}`);
|
||||
break;
|
||||
case 1: // Teilen
|
||||
onShare(item);
|
||||
break;
|
||||
case 2: // Tags bearbeiten
|
||||
router.push(`/text/${item.id}`);
|
||||
break;
|
||||
case 3: // Löschen
|
||||
onDelete(item.id, item.title);
|
||||
break;
|
||||
}
|
||||
};
|
||||
const handleMenuSelect = (index: number) => {
|
||||
switch (index) {
|
||||
case 0: // Öffnen
|
||||
router.push(`/text/${item.id}`);
|
||||
break;
|
||||
case 1: // Teilen
|
||||
onShare(item);
|
||||
break;
|
||||
case 2: // Tags bearbeiten
|
||||
router.push(`/text/${item.id}`);
|
||||
break;
|
||||
case 3: // Löschen
|
||||
onDelete(item.id, item.title);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
options={[
|
||||
{ title: 'Öffnen', systemIcon: 'doc.text' },
|
||||
{ title: 'Teilen', systemIcon: 'square.and.arrow.up' },
|
||||
{ title: 'Tags bearbeiten', systemIcon: 'tag' },
|
||||
{ title: 'Löschen', systemIcon: 'trash', destructive: true },
|
||||
]}
|
||||
onSelect={handleMenuSelect}>
|
||||
<Pressable
|
||||
onPress={() => router.push(`/text/${item.id}`)}
|
||||
className={`mb-3 rounded-lg border ${colors.border} ${colors.surface} p-4 shadow-sm`}>
|
||||
{/* Header with title and date/duration */}
|
||||
<View className="mb-2 flex-row items-start justify-between">
|
||||
<Text className={`mr-2 flex-1 text-lg font-semibold ${colors.text}`} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View className="flex-row items-center">
|
||||
<Text className={`text-sm ${colors.textTertiary}`}>{formatDate(item.updated_at)}</Text>
|
||||
{getAudioDuration(item) && (
|
||||
<>
|
||||
<Text className={`mx-1 text-sm ${colors.textTertiary}`}>•</Text>
|
||||
<Text className={`text-sm ${colors.textTertiary}`}>{getAudioDuration(item)}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<ActionMenu
|
||||
options={[
|
||||
{ title: 'Öffnen', systemIcon: 'doc.text' },
|
||||
{ title: 'Teilen', systemIcon: 'square.and.arrow.up' },
|
||||
{ title: 'Tags bearbeiten', systemIcon: 'tag' },
|
||||
{ title: 'Löschen', systemIcon: 'trash', destructive: true },
|
||||
]}
|
||||
onSelect={handleMenuSelect}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => router.push(`/text/${item.id}`)}
|
||||
className={`mb-3 rounded-lg border ${colors.border} ${colors.surface} p-4 shadow-sm`}
|
||||
>
|
||||
{/* Header with title and date/duration */}
|
||||
<View className="mb-2 flex-row items-start justify-between">
|
||||
<Text className={`mr-2 flex-1 text-lg font-semibold ${colors.text}`} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<View className="flex-row items-center">
|
||||
<Text className={`text-sm ${colors.textTertiary}`}>{formatDate(item.updated_at)}</Text>
|
||||
{getAudioDuration(item) && (
|
||||
<>
|
||||
<Text className={`mx-1 text-sm ${colors.textTertiary}`}>•</Text>
|
||||
<Text className={`text-sm ${colors.textTertiary}`}>{getAudioDuration(item)}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content preview */}
|
||||
<Text className={`mb-3 ${colors.textSecondary}`} numberOfLines={2}>
|
||||
{item.content}
|
||||
</Text>
|
||||
{/* Content preview */}
|
||||
<Text className={`mb-3 ${colors.textSecondary}`} numberOfLines={2}>
|
||||
{item.content}
|
||||
</Text>
|
||||
|
||||
{/* Footer with tags and audio player */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center">
|
||||
{item.data.tags?.map((tag, index) => (
|
||||
<View key={index} className={`mr-2 rounded-full ${colors.primaryLight} px-2 py-1`}>
|
||||
<Text className="text-xs text-blue-800">{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* Footer with tags and audio player */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center">
|
||||
{item.data.tags?.map((tag, index) => (
|
||||
<View key={index} className={`mr-2 rounded-full ${colors.primaryLight} px-2 py-1`}>
|
||||
<Text className="text-xs text-blue-800">{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center">
|
||||
<MinimalAudioPlayer text={item} />
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ActionMenu>
|
||||
);
|
||||
<View className="flex-row items-center">
|
||||
<MinimalAudioPlayer text={item} />
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ActionMenu>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,132 +4,141 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
interface DropdownOption {
|
||||
label: string;
|
||||
value: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DropdownGroup {
|
||||
title: string;
|
||||
options: DropdownOption[];
|
||||
title: string;
|
||||
options: DropdownOption[];
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
options: DropdownOption[];
|
||||
groups?: DropdownGroup[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
options: DropdownOption[];
|
||||
groups?: DropdownGroup[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
options,
|
||||
groups,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = 'Select an option',
|
||||
disabled = false,
|
||||
title = 'Select Option',
|
||||
options,
|
||||
groups,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = 'Select an option',
|
||||
disabled = false,
|
||||
title = 'Select Option',
|
||||
}: DropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { colors } = useTheme();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { colors } = useTheme();
|
||||
|
||||
// Find selected option from either flat options or groups
|
||||
const allOptions = groups ? groups.flatMap((g) => g.options) : options;
|
||||
const selectedOption = allOptions.find((opt) => opt.value === value);
|
||||
// Find selected option from either flat options or groups
|
||||
const allOptions = groups ? groups.flatMap((g) => g.options) : options;
|
||||
const selectedOption = allOptions.find((opt) => opt.value === value);
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onValueChange(optionValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onValueChange(optionValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={() => !disabled && setIsOpen(true)}
|
||||
className={`flex-row items-center justify-between rounded-lg border ${colors.border} ${colors.surface} px-4 py-3 ${
|
||||
disabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
disabled={disabled}>
|
||||
<Text
|
||||
className={`flex-1 ${selectedOption ? colors.text : colors.textSecondary}`}
|
||||
numberOfLines={1}>
|
||||
{selectedOption?.label || placeholder}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={isOpen ? 'chevron-up' : 'chevron-down'}
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={() => !disabled && setIsOpen(true)}
|
||||
className={`flex-row items-center justify-between rounded-lg border ${colors.border} ${colors.surface} px-4 py-3 ${
|
||||
disabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text
|
||||
className={`flex-1 ${selectedOption ? colors.text : colors.textSecondary}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedOption?.label || placeholder}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={isOpen ? 'chevron-up' : 'chevron-down'}
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsOpen(false)}>
|
||||
<Pressable
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)' }}
|
||||
onPress={() => setIsOpen(false)}>
|
||||
<View className="flex-1 justify-center px-4">
|
||||
<Pressable
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
className={`max-h-[80%] rounded-xl border ${colors.border} ${colors.surface} shadow-xl`}>
|
||||
<View className={`border-b ${colors.border} px-4 py-3`}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className={`text-lg font-semibold ${colors.text}`}>{title}</Text>
|
||||
<TouchableOpacity onPress={() => setIsOpen(false)}>
|
||||
<Ionicons name="close-circle" size={24} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView className="px-2 py-2" showsVerticalScrollIndicator={true}>
|
||||
{groups
|
||||
? // Render grouped options
|
||||
groups.map((group, groupIndex) => (
|
||||
<View key={group.title} className={groupIndex > 0 ? 'mt-4' : ''}>
|
||||
<Text className={`mx-2 mb-2 text-sm font-bold ${colors.textSecondary}`}>
|
||||
{group.title}
|
||||
</Text>
|
||||
{group.options.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
|
||||
option.value === value ? colors.primary : colors.surfaceSecondary
|
||||
}`}>
|
||||
<Text
|
||||
className={`${
|
||||
option.value === value ? 'font-medium text-white' : colors.text
|
||||
}`}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
: // Render flat options
|
||||
options.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
|
||||
option.value === value ? colors.primary : colors.surfaceSecondary
|
||||
}`}>
|
||||
<Text
|
||||
className={`${
|
||||
option.value === value ? 'font-medium text-white' : colors.text
|
||||
}`}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
>
|
||||
<Pressable
|
||||
style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)' }}
|
||||
onPress={() => setIsOpen(false)}
|
||||
>
|
||||
<View className="flex-1 justify-center px-4">
|
||||
<Pressable
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
className={`max-h-[80%] rounded-xl border ${colors.border} ${colors.surface} shadow-xl`}
|
||||
>
|
||||
<View className={`border-b ${colors.border} px-4 py-3`}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className={`text-lg font-semibold ${colors.text}`}>{title}</Text>
|
||||
<TouchableOpacity onPress={() => setIsOpen(false)}>
|
||||
<Ionicons name="close-circle" size={24} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView className="px-2 py-2" showsVerticalScrollIndicator={true}>
|
||||
{groups
|
||||
? // Render grouped options
|
||||
groups.map((group, groupIndex) => (
|
||||
<View key={group.title} className={groupIndex > 0 ? 'mt-4' : ''}>
|
||||
<Text className={`mx-2 mb-2 text-sm font-bold ${colors.textSecondary}`}>
|
||||
{group.title}
|
||||
</Text>
|
||||
{group.options.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
|
||||
option.value === value ? colors.primary : colors.surfaceSecondary
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`${
|
||||
option.value === value ? 'font-medium text-white' : colors.text
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
: // Render flat options
|
||||
options.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleSelect(option.value)}
|
||||
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
|
||||
option.value === value ? colors.primary : colors.surfaceSecondary
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`${
|
||||
option.value === value ? 'font-medium text-white' : colors.text
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,386 +1,386 @@
|
|||
export type VoiceProvider = 'google' | 'elevenlabs' | 'openai';
|
||||
|
||||
export interface Voice {
|
||||
value: string;
|
||||
label: string;
|
||||
gender: 'male' | 'female';
|
||||
quality: 'premium' | 'neural' | 'wavenet' | 'studio' | 'standard';
|
||||
language: string;
|
||||
provider: VoiceProvider;
|
||||
value: string;
|
||||
label: string;
|
||||
gender: 'male' | 'female';
|
||||
quality: 'premium' | 'neural' | 'wavenet' | 'studio' | 'standard';
|
||||
language: string;
|
||||
provider: VoiceProvider;
|
||||
}
|
||||
|
||||
export const GERMAN_VOICES: Voice[] = [
|
||||
// Note: Google Chirp HD voices (de-DE-Chirp3-HD-*) are available but not included here
|
||||
// as they require special API access and are significantly more expensive.
|
||||
// Add them if you have access: https://cloud.google.com/text-to-speech/docs/voices
|
||||
// Note: Google Chirp HD voices (de-DE-Chirp3-HD-*) are available but not included here
|
||||
// as they require special API access and are significantly more expensive.
|
||||
// Add them if you have access: https://cloud.google.com/text-to-speech/docs/voices
|
||||
|
||||
// Google Cloud TTS - Neural2 voices (most commonly used, good balance of quality and cost)
|
||||
{
|
||||
value: 'de-DE-Neural2-A',
|
||||
label: 'Neural2 A (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-B',
|
||||
label: 'Neural2 B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-C',
|
||||
label: 'Neural2 C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-D',
|
||||
label: 'Neural2 D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-E',
|
||||
label: 'Neural2 E (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-F',
|
||||
label: 'Neural2 F (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
// Google Cloud TTS - Neural2 voices (most commonly used, good balance of quality and cost)
|
||||
{
|
||||
value: 'de-DE-Neural2-A',
|
||||
label: 'Neural2 A (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-B',
|
||||
label: 'Neural2 B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-C',
|
||||
label: 'Neural2 C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-D',
|
||||
label: 'Neural2 D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-E',
|
||||
label: 'Neural2 E (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Neural2-F',
|
||||
label: 'Neural2 F (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
|
||||
// Google Cloud TTS - WaveNet voices (high quality, natural sounding)
|
||||
{
|
||||
value: 'de-DE-Wavenet-A',
|
||||
label: 'WaveNet A (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-B',
|
||||
label: 'WaveNet B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-C',
|
||||
label: 'WaveNet C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-D',
|
||||
label: 'WaveNet D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-E',
|
||||
label: 'WaveNet E (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-F',
|
||||
label: 'WaveNet F (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
// Google Cloud TTS - WaveNet voices (high quality, natural sounding)
|
||||
{
|
||||
value: 'de-DE-Wavenet-A',
|
||||
label: 'WaveNet A (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-B',
|
||||
label: 'WaveNet B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-C',
|
||||
label: 'WaveNet C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-D',
|
||||
label: 'WaveNet D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-E',
|
||||
label: 'WaveNet E (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Wavenet-F',
|
||||
label: 'WaveNet F (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'wavenet',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
|
||||
// Google Cloud TTS - Studio voices (broadcast quality)
|
||||
{
|
||||
value: 'de-DE-Studio-B',
|
||||
label: 'Studio B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'studio',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Studio-C',
|
||||
label: 'Studio C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'studio',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
// Google Cloud TTS - Studio voices (broadcast quality)
|
||||
{
|
||||
value: 'de-DE-Studio-B',
|
||||
label: 'Studio B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'studio',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Studio-C',
|
||||
label: 'Studio C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'studio',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
|
||||
// Google Cloud TTS - Standard voices (basic quality, lowest cost)
|
||||
{
|
||||
value: 'de-DE-Standard-A',
|
||||
label: 'Standard A (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-B',
|
||||
label: 'Standard B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-C',
|
||||
label: 'Standard C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-D',
|
||||
label: 'Standard D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-E',
|
||||
label: 'Standard E (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-F',
|
||||
label: 'Standard F (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
// Google Cloud TTS - Standard voices (basic quality, lowest cost)
|
||||
{
|
||||
value: 'de-DE-Standard-A',
|
||||
label: 'Standard A (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-B',
|
||||
label: 'Standard B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-C',
|
||||
label: 'Standard C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-D',
|
||||
label: 'Standard D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-E',
|
||||
label: 'Standard E (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'de-DE-Standard-F',
|
||||
label: 'Standard F (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'standard',
|
||||
language: 'de-DE',
|
||||
provider: 'google',
|
||||
},
|
||||
|
||||
// ElevenLabs voices
|
||||
{
|
||||
value: 'eleven_multilingual_v2',
|
||||
label: 'Rachel (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'premium',
|
||||
language: 'de-DE',
|
||||
provider: 'elevenlabs',
|
||||
},
|
||||
{
|
||||
value: 'eleven_multilingual_v1',
|
||||
label: 'Adam (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'premium',
|
||||
language: 'de-DE',
|
||||
provider: 'elevenlabs',
|
||||
},
|
||||
{
|
||||
value: 'eleven_turbo_v2',
|
||||
label: 'Turbo Rachel (Weiblich) - Low Latency',
|
||||
gender: 'female',
|
||||
quality: 'premium',
|
||||
language: 'de-DE',
|
||||
provider: 'elevenlabs',
|
||||
},
|
||||
{
|
||||
value: 'eleven_monolingual_v1',
|
||||
label: 'Clyde (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'premium',
|
||||
language: 'de-DE',
|
||||
provider: 'elevenlabs',
|
||||
},
|
||||
// ElevenLabs voices
|
||||
{
|
||||
value: 'eleven_multilingual_v2',
|
||||
label: 'Rachel (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'premium',
|
||||
language: 'de-DE',
|
||||
provider: 'elevenlabs',
|
||||
},
|
||||
{
|
||||
value: 'eleven_multilingual_v1',
|
||||
label: 'Adam (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'premium',
|
||||
language: 'de-DE',
|
||||
provider: 'elevenlabs',
|
||||
},
|
||||
{
|
||||
value: 'eleven_turbo_v2',
|
||||
label: 'Turbo Rachel (Weiblich) - Low Latency',
|
||||
gender: 'female',
|
||||
quality: 'premium',
|
||||
language: 'de-DE',
|
||||
provider: 'elevenlabs',
|
||||
},
|
||||
{
|
||||
value: 'eleven_monolingual_v1',
|
||||
label: 'Clyde (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'premium',
|
||||
language: 'de-DE',
|
||||
provider: 'elevenlabs',
|
||||
},
|
||||
|
||||
// OpenAI voices
|
||||
{
|
||||
value: 'alloy',
|
||||
label: 'Alloy (Neutral)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'echo',
|
||||
label: 'Echo (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'fable',
|
||||
label: 'Fable (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'onyx',
|
||||
label: 'Onyx (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'nova',
|
||||
label: 'Nova (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'shimmer',
|
||||
label: 'Shimmer (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
// OpenAI voices
|
||||
{
|
||||
value: 'alloy',
|
||||
label: 'Alloy (Neutral)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'echo',
|
||||
label: 'Echo (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'fable',
|
||||
label: 'Fable (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'onyx',
|
||||
label: 'Onyx (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'nova',
|
||||
label: 'Nova (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
{
|
||||
value: 'shimmer',
|
||||
label: 'Shimmer (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'de-DE',
|
||||
provider: 'openai',
|
||||
},
|
||||
];
|
||||
|
||||
export const ENGLISH_US_VOICES: Voice[] = [
|
||||
// Google Cloud TTS - Neural2 voices
|
||||
{
|
||||
value: 'en-US-Neural2-A',
|
||||
label: 'Neural2 A (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'en-US',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-US-Neural2-C',
|
||||
label: 'Neural2 C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'en-US',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-US-Neural2-D',
|
||||
label: 'Neural2 D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'en-US',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-US-Neural2-E',
|
||||
label: 'Neural2 E (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'en-US',
|
||||
provider: 'google',
|
||||
},
|
||||
// Google Cloud TTS - Neural2 voices
|
||||
{
|
||||
value: 'en-US-Neural2-A',
|
||||
label: 'Neural2 A (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'en-US',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-US-Neural2-C',
|
||||
label: 'Neural2 C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'en-US',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-US-Neural2-D',
|
||||
label: 'Neural2 D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'en-US',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-US-Neural2-E',
|
||||
label: 'Neural2 E (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'en-US',
|
||||
provider: 'google',
|
||||
},
|
||||
];
|
||||
|
||||
export const ENGLISH_GB_VOICES: Voice[] = [
|
||||
// Google Cloud TTS - Neural2 voices
|
||||
{
|
||||
value: 'en-GB-Neural2-A',
|
||||
label: 'Neural2 A (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'en-GB',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-GB-Neural2-B',
|
||||
label: 'Neural2 B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'en-GB',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-GB-Neural2-C',
|
||||
label: 'Neural2 C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'en-GB',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-GB-Neural2-D',
|
||||
label: 'Neural2 D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'en-GB',
|
||||
provider: 'google',
|
||||
},
|
||||
// Google Cloud TTS - Neural2 voices
|
||||
{
|
||||
value: 'en-GB-Neural2-A',
|
||||
label: 'Neural2 A (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'en-GB',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-GB-Neural2-B',
|
||||
label: 'Neural2 B (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'en-GB',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-GB-Neural2-C',
|
||||
label: 'Neural2 C (Weiblich)',
|
||||
gender: 'female',
|
||||
quality: 'neural',
|
||||
language: 'en-GB',
|
||||
provider: 'google',
|
||||
},
|
||||
{
|
||||
value: 'en-GB-Neural2-D',
|
||||
label: 'Neural2 D (Männlich)',
|
||||
gender: 'male',
|
||||
quality: 'neural',
|
||||
language: 'en-GB',
|
||||
provider: 'google',
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_VOICES = [...GERMAN_VOICES, ...ENGLISH_US_VOICES, ...ENGLISH_GB_VOICES];
|
||||
|
||||
export const getVoicesByLanguage = (language: string): Voice[] => {
|
||||
return ALL_VOICES.filter((voice) => voice.language === language);
|
||||
return ALL_VOICES.filter((voice) => voice.language === language);
|
||||
};
|
||||
|
||||
export const getVoiceById = (voiceId: string): Voice | undefined => {
|
||||
if (!voiceId) return undefined;
|
||||
if (!voiceId) return undefined;
|
||||
|
||||
try {
|
||||
const allVoices = [...GERMAN_VOICES, ...ENGLISH_US_VOICES, ...ENGLISH_GB_VOICES];
|
||||
return allVoices.find((voice) => voice.value === voiceId);
|
||||
} catch (error) {
|
||||
console.error('Error in getVoiceById:', error);
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const allVoices = [...GERMAN_VOICES, ...ENGLISH_US_VOICES, ...ENGLISH_GB_VOICES];
|
||||
return allVoices.find((voice) => voice.value === voiceId);
|
||||
} catch (error) {
|
||||
console.error('Error in getVoiceById:', error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const QUALITY_LABELS: Record<Voice['quality'], string> = {
|
||||
premium: '🌟 Premium',
|
||||
neural: '🧠 Neural',
|
||||
wavenet: '🌊 WaveNet',
|
||||
studio: '🎙️ Studio',
|
||||
standard: '📢 Standard',
|
||||
premium: '🌟 Premium',
|
||||
neural: '🧠 Neural',
|
||||
wavenet: '🌊 WaveNet',
|
||||
studio: '🎙️ Studio',
|
||||
standard: '📢 Standard',
|
||||
};
|
||||
|
||||
export const PROVIDER_LABELS: Record<VoiceProvider, string> = {
|
||||
google: '🔵 Google Cloud',
|
||||
elevenlabs: '🎯 ElevenLabs',
|
||||
openai: '🤖 OpenAI',
|
||||
google: '🔵 Google Cloud',
|
||||
elevenlabs: '🎯 ElevenLabs',
|
||||
openai: '🤖 OpenAI',
|
||||
};
|
||||
|
||||
// Backward compatibility: map old voice codes to new voice IDs
|
||||
export const LEGACY_VOICE_MAP: Record<string, string> = {
|
||||
'de-DE': 'de-DE-Neural2-A',
|
||||
'en-US': 'en-US-Neural2-A',
|
||||
'en-GB': 'en-GB-Neural2-A',
|
||||
// Also map old voice IDs that no longer exist
|
||||
'de-DE-Neural2-G': 'de-DE-Neural2-A',
|
||||
'de-DE-Neural2-H': 'de-DE-Neural2-B',
|
||||
'de-DE-Wavenet-G': 'de-DE-Wavenet-A',
|
||||
'de-DE-Wavenet-H': 'de-DE-Wavenet-B',
|
||||
'de-DE-Standard-G': 'de-DE-Standard-A',
|
||||
'de-DE-Standard-H': 'de-DE-Standard-B',
|
||||
'de-DE': 'de-DE-Neural2-A',
|
||||
'en-US': 'en-US-Neural2-A',
|
||||
'en-GB': 'en-GB-Neural2-A',
|
||||
// Also map old voice IDs that no longer exist
|
||||
'de-DE-Neural2-G': 'de-DE-Neural2-A',
|
||||
'de-DE-Neural2-H': 'de-DE-Neural2-B',
|
||||
'de-DE-Wavenet-G': 'de-DE-Wavenet-A',
|
||||
'de-DE-Wavenet-H': 'de-DE-Wavenet-B',
|
||||
'de-DE-Standard-G': 'de-DE-Standard-A',
|
||||
'de-DE-Standard-H': 'de-DE-Standard-B',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,61 +1,68 @@
|
|||
# Browser Extension für URL-Extraktion
|
||||
|
||||
## Konzept
|
||||
|
||||
Eine Browser Extension kann direkt auf den gerenderten Content zugreifen, nachdem der Nutzer Cookies akzeptiert hat.
|
||||
|
||||
## Implementation (Chrome/Safari)
|
||||
|
||||
### Manifest.json
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Reader App Extractor",
|
||||
"permissions": ["activeTab", "clipboardWrite"],
|
||||
"action": {
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"content_scripts": [{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"]
|
||||
}]
|
||||
"manifest_version": 3,
|
||||
"name": "Reader App Extractor",
|
||||
"permissions": ["activeTab", "clipboardWrite"],
|
||||
"action": {
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content Script
|
||||
|
||||
```javascript
|
||||
// content.js
|
||||
function extractArticle() {
|
||||
// Nutze Readability direkt im Browser
|
||||
const documentClone = document.cloneNode(true);
|
||||
const reader = new Readability(documentClone);
|
||||
const article = reader.parse();
|
||||
|
||||
if (article) {
|
||||
// Sende an Reader App
|
||||
const readerUrl = `reader-app://add?title=${encodeURIComponent(article.title)}&content=${encodeURIComponent(article.content)}`;
|
||||
window.location.href = readerUrl;
|
||||
}
|
||||
// Nutze Readability direkt im Browser
|
||||
const documentClone = document.cloneNode(true);
|
||||
const reader = new Readability(documentClone);
|
||||
const article = reader.parse();
|
||||
|
||||
if (article) {
|
||||
// Sende an Reader App
|
||||
const readerUrl = `reader-app://add?title=${encodeURIComponent(article.title)}&content=${encodeURIComponent(article.content)}`;
|
||||
window.location.href = readerUrl;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration in React Native
|
||||
|
||||
```typescript
|
||||
// Deep Link Handler
|
||||
import { Linking } from 'react-native';
|
||||
|
||||
Linking.addEventListener('url', (event) => {
|
||||
const url = new URL(event.url);
|
||||
if (url.protocol === 'reader-app:' && url.pathname === 'add') {
|
||||
const title = url.searchParams.get('title');
|
||||
const content = url.searchParams.get('content');
|
||||
// Erstelle neuen Text
|
||||
}
|
||||
const url = new URL(event.url);
|
||||
if (url.protocol === 'reader-app:' && url.pathname === 'add') {
|
||||
const title = url.searchParams.get('title');
|
||||
const content = url.searchParams.get('content');
|
||||
// Erstelle neuen Text
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## iOS Share Extension Alternative
|
||||
|
||||
### Info.plist
|
||||
|
||||
```xml
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
|
|
@ -68,16 +75,17 @@ Linking.addEventListener('url', (event) => {
|
|||
```
|
||||
|
||||
### Share Extension Code
|
||||
|
||||
```swift
|
||||
import MobileCoreServices
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
||||
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let provider = item.attachments?.first {
|
||||
|
||||
|
||||
if provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
|
||||
provider.loadItem(forTypeIdentifier: kUTTypeURL as String) { (url, error) in
|
||||
if let shareURL = url as? URL {
|
||||
|
|
@ -88,11 +96,11 @@ class ShareViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func extractContent(from url: URL) {
|
||||
let webView = WKWebView()
|
||||
webView.load(URLRequest(url: url))
|
||||
|
||||
|
||||
// Nach dem Laden JavaScript ausführen
|
||||
webView.evaluateJavaScript("document.body.innerText") { (result, error) in
|
||||
if let text = result as? String {
|
||||
|
|
@ -105,12 +113,14 @@ class ShareViewController: UIViewController {
|
|||
```
|
||||
|
||||
## Vorteile
|
||||
|
||||
1. Umgeht alle Cookie-Banner (Nutzer akzeptiert im Browser)
|
||||
2. Zugriff auf den vollständig gerenderten Content
|
||||
3. Native Integration in iOS/Android Share-Menü
|
||||
4. Kein Server-Side Rendering nötig
|
||||
|
||||
## Nachteile
|
||||
|
||||
1. Zusätzliche Installation erforderlich
|
||||
2. Platform-spezifische Entwicklung
|
||||
3. App Store Review Process für Extensions
|
||||
3. App Store Review Process für Extensions
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@
|
|||
## 1. Google Cloud Setup
|
||||
|
||||
### API Key erstellen
|
||||
|
||||
1. Google Cloud Console → "APIs & Services" → "Credentials"
|
||||
2. "Create Credentials" → "API Key"
|
||||
3. API Key auf Text-to-Speech API beschränken
|
||||
|
||||
### Stimmen konfigurieren
|
||||
|
||||
Die App verwendet Google Neural2 Stimmen:
|
||||
|
||||
- `de-DE-Neural2-A` (Deutsch, weiblich)
|
||||
- `en-US-Neural2-A` (Englisch US, männlich)
|
||||
- `en-GB-Neural2-A` (Englisch UK, weiblich)
|
||||
|
|
@ -22,6 +25,7 @@ Die App verwendet Google Neural2 Stimmen:
|
|||
## 2. Supabase Setup
|
||||
|
||||
### Datenbank Migrationen
|
||||
|
||||
```bash
|
||||
# Migrations ausführen
|
||||
supabase migration up
|
||||
|
|
@ -32,12 +36,15 @@ supabase migration up
|
|||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
In Supabase Dashboard → Settings → Edge Functions:
|
||||
|
||||
```
|
||||
GOOGLE_TTS_API_KEY=your_google_api_key_here
|
||||
```
|
||||
|
||||
### Edge Functions deployen
|
||||
|
||||
```bash
|
||||
# Supabase CLI installieren
|
||||
npm install -g supabase
|
||||
|
|
@ -48,6 +55,7 @@ supabase functions deploy get-audio-url
|
|||
```
|
||||
|
||||
### Storage Setup
|
||||
|
||||
- Bucket "audio" wird automatisch erstellt
|
||||
- RLS Policies sind konfiguriert
|
||||
- Benutzer können nur ihre eigenen Audio-Dateien zugreifen
|
||||
|
|
@ -55,50 +63,56 @@ supabase functions deploy get-audio-url
|
|||
## 3. React Native App Setup
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Erstelle `.env.local`:
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
|
||||
```
|
||||
|
||||
### Dependencies installieren
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### App konfigurieren
|
||||
|
||||
In `app.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"name": "Reader",
|
||||
"slug": "reader",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.tilljs.reader"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
}
|
||||
}
|
||||
"expo": {
|
||||
"name": "Reader",
|
||||
"slug": "reader",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.tilljs.reader"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Development Testing
|
||||
|
||||
### Lokal testen
|
||||
|
||||
```bash
|
||||
# Development Server starten
|
||||
npm start
|
||||
|
|
@ -111,6 +125,7 @@ npm run android
|
|||
```
|
||||
|
||||
### Edge Functions testen
|
||||
|
||||
```bash
|
||||
# Lokal
|
||||
supabase functions serve
|
||||
|
|
@ -130,6 +145,7 @@ curl -X POST 'http://localhost:54321/functions/v1/generate-audio' \
|
|||
## 5. Production Deployment
|
||||
|
||||
### EAS Build Setup
|
||||
|
||||
```bash
|
||||
# EAS CLI installieren
|
||||
npm install -g @expo/eas-cli
|
||||
|
|
@ -142,28 +158,30 @@ eas build:configure
|
|||
```
|
||||
|
||||
### Build Profile (`eas.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 0.52.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
"cli": {
|
||||
"version": ">= 0.52.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Builds erstellen
|
||||
|
||||
```bash
|
||||
# Development Build
|
||||
eas build --profile development
|
||||
|
|
@ -173,6 +191,7 @@ eas build --profile production
|
|||
```
|
||||
|
||||
### App Store Submission
|
||||
|
||||
```bash
|
||||
# iOS App Store
|
||||
eas submit --platform ios
|
||||
|
|
@ -184,17 +203,20 @@ eas submit --platform android
|
|||
## 6. Monitoring & Maintenance
|
||||
|
||||
### Supabase Dashboard
|
||||
|
||||
- Database Performance
|
||||
- Storage Usage
|
||||
- Edge Function Logs
|
||||
- User Activity
|
||||
|
||||
### Google Cloud Monitoring
|
||||
|
||||
- API Usage
|
||||
- Kosten überwachen
|
||||
- Rate Limits prüfen
|
||||
|
||||
### App Analytics
|
||||
|
||||
- Expo Analytics
|
||||
- Crashlytics Integration
|
||||
- Performance Monitoring
|
||||
|
|
@ -202,11 +224,13 @@ eas submit --platform android
|
|||
## 7. Kosten-Optimierung
|
||||
|
||||
### Google Cloud TTS
|
||||
|
||||
- Erste 1M Zeichen/Monat kostenlos
|
||||
- Neural2 Stimmen: $16/1M Zeichen
|
||||
- Caching implementiert zur Kostenreduzierung
|
||||
|
||||
### Supabase
|
||||
|
||||
- Free Tier: 500MB DB, 1GB Storage
|
||||
- Pro Tier: $25/Monat für erweiterte Features
|
||||
- Storage: $0.021/GB/Monat
|
||||
|
|
@ -214,12 +238,14 @@ eas submit --platform android
|
|||
## 8. Sicherheit
|
||||
|
||||
### Best Practices
|
||||
|
||||
- API Keys niemals in Client-Code
|
||||
- Row Level Security (RLS) aktiviert
|
||||
- Signed URLs für Audio-Dateien
|
||||
- JWT Token Validation
|
||||
|
||||
### Regelmäßige Updates
|
||||
|
||||
- Dependencies aktualisieren
|
||||
- Sicherheitspatches einspielen
|
||||
- API Key Rotation
|
||||
|
|
@ -227,6 +253,7 @@ eas submit --platform android
|
|||
## 9. Troubleshooting
|
||||
|
||||
### Häufige Probleme
|
||||
|
||||
1. **Audio-Generierung fehlschlägt**
|
||||
- Google Cloud API Key prüfen
|
||||
- Quota-Limits prüfen
|
||||
|
|
@ -243,6 +270,7 @@ eas submit --platform android
|
|||
- Audio Format Kompatibilität
|
||||
|
||||
### Logs & Debugging
|
||||
|
||||
```bash
|
||||
# Supabase Logs
|
||||
supabase logs
|
||||
|
|
@ -257,6 +285,7 @@ expo logs
|
|||
## 10. Nächste Schritte
|
||||
|
||||
### Feature Roadmap
|
||||
|
||||
- Push Notifications
|
||||
- Offline-First Synchronisation
|
||||
- Cloud Backup
|
||||
|
|
@ -264,7 +293,8 @@ expo logs
|
|||
- Advanced Audio Controls
|
||||
|
||||
### Performance Optimierung
|
||||
|
||||
- Image Optimization
|
||||
- Bundle Size Reduction
|
||||
- Lazy Loading
|
||||
- Background Processing
|
||||
- Background Processing
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ GOOGLE_TTS_API_KEY=dein_api_key_hier
|
|||
## 6. Verfügbare Google Cloud TTS Voices
|
||||
|
||||
### Deutsch (de-DE)
|
||||
|
||||
#### Neural2 Voices (Empfohlen - beste Balance zwischen Qualität und Kosten)
|
||||
|
||||
- `de-DE-Neural2-A` (weiblich)
|
||||
- `de-DE-Neural2-B` (männlich)
|
||||
- `de-DE-Neural2-C` (weiblich)
|
||||
|
|
@ -51,6 +53,7 @@ GOOGLE_TTS_API_KEY=dein_api_key_hier
|
|||
- `de-DE-Neural2-F` (männlich)
|
||||
|
||||
#### WaveNet Voices (Hochqualitativ)
|
||||
|
||||
- `de-DE-Wavenet-A` (weiblich)
|
||||
- `de-DE-Wavenet-B` (männlich)
|
||||
- `de-DE-Wavenet-C` (weiblich)
|
||||
|
|
@ -59,10 +62,12 @@ GOOGLE_TTS_API_KEY=dein_api_key_hier
|
|||
- `de-DE-Wavenet-F` (männlich)
|
||||
|
||||
#### Studio Voices (Broadcast-Qualität)
|
||||
|
||||
- `de-DE-Studio-B` (männlich)
|
||||
- `de-DE-Studio-C` (weiblich)
|
||||
|
||||
#### Standard Voices (Basis-Qualität, günstigste Option)
|
||||
|
||||
- `de-DE-Standard-A` (weiblich)
|
||||
- `de-DE-Standard-B` (männlich)
|
||||
- `de-DE-Standard-C` (weiblich)
|
||||
|
|
@ -71,12 +76,14 @@ GOOGLE_TTS_API_KEY=dein_api_key_hier
|
|||
- `de-DE-Standard-F` (männlich)
|
||||
|
||||
### Englisch (US)
|
||||
|
||||
- `en-US-Neural2-A` (männlich)
|
||||
- `en-US-Neural2-C` (weiblich)
|
||||
- `en-US-Neural2-D` (männlich)
|
||||
- `en-US-Neural2-E` (weiblich)
|
||||
|
||||
### Englisch (UK)
|
||||
|
||||
- `en-GB-Neural2-A` (weiblich)
|
||||
- `en-GB-Neural2-B` (männlich)
|
||||
- `en-GB-Neural2-C` (weiblich)
|
||||
|
|
@ -89,6 +96,7 @@ GOOGLE_TTS_API_KEY=dein_api_key_hier
|
|||
- **Erstes 1 Million Zeichen pro Monat**: Kostenlos
|
||||
|
||||
### Beispielrechnung für 10.000 Zeichen:
|
||||
|
||||
- Standard: $0.04
|
||||
- Neural2: $0.16
|
||||
|
||||
|
|
@ -123,4 +131,4 @@ curl -X POST \
|
|||
- API Key niemals in Client-Code einbetten
|
||||
- Nur über Supabase Edge Functions verwenden
|
||||
- Regelmäßige Rotation der API Keys
|
||||
- Monitoring der API-Nutzung einrichten
|
||||
- Monitoring der API-Nutzung einrichten
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
# URL-Extraktion Optionen
|
||||
|
||||
## Problem
|
||||
|
||||
Viele Webseiten zeigen Cookie-Banner oder andere Overlays, die den eigentlichen Inhalt blockieren.
|
||||
|
||||
## Lösungsoptionen
|
||||
|
||||
### 1. **ScrapingBee API** (Empfohlen für Production)
|
||||
- **Vorteile**:
|
||||
|
||||
- **Vorteile**:
|
||||
- JavaScript-Rendering
|
||||
- Automatisches Cookie-Banner-Handling
|
||||
- Anti-Bot-Umgehung
|
||||
- Einfache Integration
|
||||
- **Nachteile**:
|
||||
- **Nachteile**:
|
||||
- Kostenpflichtig (1000 kostenlose Credits/Monat)
|
||||
- API-Key erforderlich
|
||||
- **Setup**:
|
||||
|
|
@ -19,52 +21,60 @@ Viele Webseiten zeigen Cookie-Banner oder andere Overlays, die den eigentlichen
|
|||
2. API-Key in Supabase Secrets speichern: `SCRAPINGBEE_API_KEY`
|
||||
3. Edge Function `extract-url-scrapingbee` deployen
|
||||
|
||||
### 2. **Browserless.io**
|
||||
- **Vorteile**:
|
||||
### 2. **Browserless.io**
|
||||
|
||||
- **Vorteile**:
|
||||
- Headless Chrome as a Service
|
||||
- Puppeteer/Playwright kompatibel
|
||||
- Cookie-Banner können programmatisch geklickt werden
|
||||
- **Nachteile**:
|
||||
- **Nachteile**:
|
||||
- Kostenpflichtig
|
||||
- Komplexere Integration
|
||||
- **Code-Beispiel**:
|
||||
|
||||
```typescript
|
||||
const browserlessUrl = `https://chrome.browserless.io/content?token=${BROWSERLESS_TOKEN}`;
|
||||
const response = await fetch(browserlessUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: targetUrl,
|
||||
waitFor: 3000,
|
||||
scripts: [{
|
||||
content: `document.querySelectorAll('[class*="cookie"] button').forEach(b => b.click())`
|
||||
}]
|
||||
})
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: targetUrl,
|
||||
waitFor: 3000,
|
||||
scripts: [
|
||||
{
|
||||
content: `document.querySelectorAll('[class*="cookie"] button').forEach(b => b.click())`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### 3. **Reader API von Jina.ai** (Einfachste Lösung)
|
||||
- **Vorteile**:
|
||||
|
||||
- **Vorteile**:
|
||||
- Kostenlos
|
||||
- Keine Registrierung
|
||||
- Einfache Integration
|
||||
- **Nachteile**:
|
||||
- **Nachteile**:
|
||||
- Weniger Kontrolle
|
||||
- Rate Limits
|
||||
- **Implementierung**:
|
||||
|
||||
```typescript
|
||||
const response = await fetch(`https://r.jina.ai/${encodeURIComponent(url)}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-With-Images': 'false'
|
||||
}
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-With-Images': 'false',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 4. **Client-seitige Lösung** (iOS/Android)
|
||||
|
||||
- **iOS**: SFSafariViewController mit Reader Mode
|
||||
- **Android**: Chrome Custom Tabs mit Reader Mode
|
||||
- **React Native**:
|
||||
- **React Native**:
|
||||
|
||||
```typescript
|
||||
import { WebView } from 'react-native-webview';
|
||||
|
||||
|
|
@ -78,29 +88,33 @@ const injectedJS = `
|
|||
```
|
||||
|
||||
### 5. **Proxy-Service mit Playwright**
|
||||
|
||||
Eigener Service auf Vercel/Railway:
|
||||
|
||||
```typescript
|
||||
// api/extract.ts
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(req.query.url);
|
||||
|
||||
// Warte auf Content und klicke Cookie-Banner weg
|
||||
await page.waitForTimeout(2000);
|
||||
await page.click('text=/akzeptieren|accept|agree/i').catch(() => {});
|
||||
|
||||
const content = await page.evaluate(() => {
|
||||
return document.querySelector('article')?.innerText ||
|
||||
document.querySelector('main')?.innerText ||
|
||||
document.body.innerText;
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
res.json({ content });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto(req.query.url);
|
||||
|
||||
// Warte auf Content und klicke Cookie-Banner weg
|
||||
await page.waitForTimeout(2000);
|
||||
await page.click('text=/akzeptieren|accept|agree/i').catch(() => {});
|
||||
|
||||
const content = await page.evaluate(() => {
|
||||
return (
|
||||
document.querySelector('article')?.innerText ||
|
||||
document.querySelector('main')?.innerText ||
|
||||
document.body.innerText
|
||||
);
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
res.json({ content });
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -114,20 +128,22 @@ Für Production: **ScrapingBee** mit Fallback auf direkte Extraktion
|
|||
```typescript
|
||||
// In extract-url Edge Function
|
||||
try {
|
||||
// Versuche zuerst Jina.ai
|
||||
const jinaResponse = await fetch(`https://r.jina.ai/${url}`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
if (jinaResponse.ok) {
|
||||
const data = await jinaResponse.json();
|
||||
return new Response(JSON.stringify({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
// ... weitere Felder
|
||||
}));
|
||||
}
|
||||
// Versuche zuerst Jina.ai
|
||||
const jinaResponse = await fetch(`https://r.jina.ai/${url}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (jinaResponse.ok) {
|
||||
const data = await jinaResponse.json();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
// ... weitere Felder
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback auf normale Extraktion
|
||||
// Fallback auf normale Extraktion
|
||||
}
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ const { defineConfig } = require('eslint/config');
|
|||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*', 'supabase/functions/**/*', '.expo/**/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
},
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*', 'supabase/functions/**/*', '.expo/**/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -6,411 +6,411 @@ import { useStore } from '~/store/store';
|
|||
import { AudioChunk } from '~/types/database';
|
||||
|
||||
export interface AudioState {
|
||||
isPlaying: boolean;
|
||||
isLoading: boolean;
|
||||
currentPosition: number;
|
||||
duration: number;
|
||||
currentChunk?: AudioChunk;
|
||||
sound?: Audio.Sound;
|
||||
playbackRate: number;
|
||||
isPlaying: boolean;
|
||||
isLoading: boolean;
|
||||
currentPosition: number;
|
||||
duration: number;
|
||||
currentChunk?: AudioChunk;
|
||||
sound?: Audio.Sound;
|
||||
playbackRate: number;
|
||||
}
|
||||
|
||||
export const useAudio = () => {
|
||||
const { settings, updateSettings } = useStore();
|
||||
const { updateText } = useTexts();
|
||||
const { settings, updateSettings } = useStore();
|
||||
const { updateText } = useTexts();
|
||||
|
||||
const [audioState, setAudioState] = useState<AudioState>({
|
||||
isPlaying: false,
|
||||
isLoading: false,
|
||||
currentPosition: 0,
|
||||
duration: 0,
|
||||
playbackRate: settings.playbackRate || 1.0,
|
||||
});
|
||||
const [audioState, setAudioState] = useState<AudioState>({
|
||||
isPlaying: false,
|
||||
isLoading: false,
|
||||
currentPosition: 0,
|
||||
duration: 0,
|
||||
playbackRate: settings.playbackRate || 1.0,
|
||||
});
|
||||
|
||||
const [generationProgress, setGenerationProgress] = useState<AudioGenerationProgress | null>(
|
||||
null
|
||||
);
|
||||
const [downloadProgress, setDownloadProgress] = useState<{
|
||||
completed: number;
|
||||
total: number;
|
||||
currentChunk: string;
|
||||
} | null>(null);
|
||||
const [generationProgress, setGenerationProgress] = useState<AudioGenerationProgress | null>(
|
||||
null
|
||||
);
|
||||
const [downloadProgress, setDownloadProgress] = useState<{
|
||||
completed: number;
|
||||
total: number;
|
||||
currentChunk: string;
|
||||
} | null>(null);
|
||||
|
||||
const { setCurrentText, setIsPlaying, setCurrentPosition } = useStore();
|
||||
const audioService = AudioService.getInstance();
|
||||
const { setCurrentText, setIsPlaying, setCurrentPosition } = useStore();
|
||||
const audioService = AudioService.getInstance();
|
||||
|
||||
// Initialize audio session
|
||||
useEffect(() => {
|
||||
const initializeAudio = async () => {
|
||||
try {
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: false,
|
||||
playsInSilentModeIOS: true,
|
||||
shouldDuckAndroid: true,
|
||||
staysActiveInBackground: true,
|
||||
playThroughEarpieceAndroid: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing audio:', error);
|
||||
}
|
||||
};
|
||||
// Initialize audio session
|
||||
useEffect(() => {
|
||||
const initializeAudio = async () => {
|
||||
try {
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: false,
|
||||
playsInSilentModeIOS: true,
|
||||
shouldDuckAndroid: true,
|
||||
staysActiveInBackground: true,
|
||||
playThroughEarpieceAndroid: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing audio:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeAudio();
|
||||
}, []);
|
||||
initializeAudio();
|
||||
}, []);
|
||||
|
||||
// Clean up audio when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioState.sound) {
|
||||
audioState.sound.unloadAsync();
|
||||
}
|
||||
};
|
||||
}, [audioState.sound]);
|
||||
// Clean up audio when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioState.sound) {
|
||||
audioState.sound.unloadAsync();
|
||||
}
|
||||
};
|
||||
}, [audioState.sound]);
|
||||
|
||||
// Generate audio for a text
|
||||
const generateAudio = useCallback(
|
||||
async (
|
||||
textId: string,
|
||||
content: string,
|
||||
voice: string = 'de-DE',
|
||||
speed: number = 1.0,
|
||||
currentText?: any
|
||||
) => {
|
||||
try {
|
||||
setGenerationProgress({
|
||||
chunksCompleted: 0,
|
||||
totalChunks: 1,
|
||||
currentChunk: 'Starting...',
|
||||
isComplete: false,
|
||||
});
|
||||
// Generate audio for a text
|
||||
const generateAudio = useCallback(
|
||||
async (
|
||||
textId: string,
|
||||
content: string,
|
||||
voice: string = 'de-DE',
|
||||
speed: number = 1.0,
|
||||
currentText?: any
|
||||
) => {
|
||||
try {
|
||||
setGenerationProgress({
|
||||
chunksCompleted: 0,
|
||||
totalChunks: 1,
|
||||
currentChunk: 'Starting...',
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
// Import migration helper
|
||||
const { generateVersionId } = await import('~/utils/audioMigration');
|
||||
const newVersionId = generateVersionId();
|
||||
// Import migration helper
|
||||
const { generateVersionId } = await import('~/utils/audioMigration');
|
||||
const newVersionId = generateVersionId();
|
||||
|
||||
const result = await audioService.generateAudioForText(
|
||||
textId,
|
||||
content,
|
||||
voice,
|
||||
speed,
|
||||
1000,
|
||||
setGenerationProgress,
|
||||
newVersionId
|
||||
);
|
||||
const result = await audioService.generateAudioForText(
|
||||
textId,
|
||||
content,
|
||||
voice,
|
||||
speed,
|
||||
1000,
|
||||
setGenerationProgress,
|
||||
newVersionId
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
// Get current text to append to audioVersions
|
||||
if (!currentText) {
|
||||
throw new Error('Text must be provided to generate audio');
|
||||
}
|
||||
// Get current text to append to audioVersions
|
||||
if (!currentText) {
|
||||
throw new Error('Text must be provided to generate audio');
|
||||
}
|
||||
|
||||
// Import migration helper for existing code
|
||||
const { migrateAudioData } = await import('~/utils/audioMigration');
|
||||
// Import migration helper for existing code
|
||||
const { migrateAudioData } = await import('~/utils/audioMigration');
|
||||
|
||||
// Migrate old data if needed
|
||||
const migratedData = migrateAudioData(currentText.data);
|
||||
const newAudioVersion = {
|
||||
id: newVersionId,
|
||||
chunks: result.chunks || [],
|
||||
settings: { voice, speed },
|
||||
totalSize: result.chunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
|
||||
hasLocalCache: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
// Migrate old data if needed
|
||||
const migratedData = migrateAudioData(currentText.data);
|
||||
const newAudioVersion = {
|
||||
id: newVersionId,
|
||||
chunks: result.chunks || [],
|
||||
settings: { voice, speed },
|
||||
totalSize: result.chunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
|
||||
hasLocalCache: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Append new version to audioVersions
|
||||
const updatedAudioVersions = [...(migratedData.audioVersions || []), newAudioVersion];
|
||||
// Append new version to audioVersions
|
||||
const updatedAudioVersions = [...(migratedData.audioVersions || []), newAudioVersion];
|
||||
|
||||
// Update text with new audio version
|
||||
await updateText(textId, {
|
||||
data: {
|
||||
...migratedData,
|
||||
audioVersions: updatedAudioVersions,
|
||||
currentAudioVersion: newVersionId,
|
||||
// Keep legacy audio field for backward compatibility
|
||||
audio: {
|
||||
hasLocalCache: false,
|
||||
chunks: result.chunks || [],
|
||||
totalSize: newAudioVersion.totalSize,
|
||||
lastGenerated: newAudioVersion.createdAt,
|
||||
settings: { voice, speed },
|
||||
},
|
||||
},
|
||||
});
|
||||
// Update text with new audio version
|
||||
await updateText(textId, {
|
||||
data: {
|
||||
...migratedData,
|
||||
audioVersions: updatedAudioVersions,
|
||||
currentAudioVersion: newVersionId,
|
||||
// Keep legacy audio field for backward compatibility
|
||||
audio: {
|
||||
hasLocalCache: false,
|
||||
chunks: result.chunks || [],
|
||||
totalSize: newAudioVersion.totalSize,
|
||||
lastGenerated: newAudioVersion.createdAt,
|
||||
settings: { voice, speed },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error generating audio:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setGenerationProgress(null);
|
||||
}
|
||||
},
|
||||
[audioService, updateText]
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error generating audio:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setGenerationProgress(null);
|
||||
}
|
||||
},
|
||||
[audioService, updateText]
|
||||
);
|
||||
|
||||
// Download audio chunks to local storage
|
||||
const downloadAudio = useCallback(
|
||||
async (textId: string, chunks: AudioChunk[]) => {
|
||||
try {
|
||||
setDownloadProgress({
|
||||
completed: 0,
|
||||
total: chunks.length,
|
||||
currentChunk: 'Starting download...',
|
||||
});
|
||||
// Download audio chunks to local storage
|
||||
const downloadAudio = useCallback(
|
||||
async (textId: string, chunks: AudioChunk[]) => {
|
||||
try {
|
||||
setDownloadProgress({
|
||||
completed: 0,
|
||||
total: chunks.length,
|
||||
currentChunk: 'Starting download...',
|
||||
});
|
||||
|
||||
const result = await audioService.downloadAudioChunks(textId, chunks, setDownloadProgress);
|
||||
const result = await audioService.downloadAudioChunks(textId, chunks, setDownloadProgress);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
if (!result.success) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
// Update text to mark as locally cached
|
||||
await updateText(textId, {
|
||||
data: {
|
||||
audio: {
|
||||
hasLocalCache: true,
|
||||
chunks: result.localChunks || chunks,
|
||||
totalSize: result.localChunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
|
||||
lastGenerated: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
// Update text to mark as locally cached
|
||||
await updateText(textId, {
|
||||
data: {
|
||||
audio: {
|
||||
hasLocalCache: true,
|
||||
chunks: result.localChunks || chunks,
|
||||
totalSize: result.localChunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
|
||||
lastGenerated: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error downloading audio:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setDownloadProgress(null);
|
||||
}
|
||||
},
|
||||
[audioService, updateText]
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error downloading audio:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setDownloadProgress(null);
|
||||
}
|
||||
},
|
||||
[audioService, updateText]
|
||||
);
|
||||
|
||||
// Play audio from local cache
|
||||
const playAudio = useCallback(
|
||||
async (textId: string, chunks: AudioChunk[], startPosition: number = 0) => {
|
||||
try {
|
||||
setAudioState((prev) => ({ ...prev, isLoading: true }));
|
||||
// Play audio from local cache
|
||||
const playAudio = useCallback(
|
||||
async (textId: string, chunks: AudioChunk[], startPosition: number = 0) => {
|
||||
try {
|
||||
setAudioState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
// Stop current audio if playing
|
||||
if (audioState.sound) {
|
||||
audioState.sound.unloadAsync();
|
||||
}
|
||||
// Stop current audio if playing
|
||||
if (audioState.sound) {
|
||||
audioState.sound.unloadAsync();
|
||||
}
|
||||
|
||||
// Calculate total duration from all chunks
|
||||
const totalDuration = chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000; // Convert to milliseconds
|
||||
// Calculate total duration from all chunks
|
||||
const totalDuration = chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000; // Convert to milliseconds
|
||||
|
||||
const result = await audioService.playAudioFromSupabase(textId, chunks, startPosition);
|
||||
const result = await audioService.playAudioFromSupabase(textId, chunks, startPosition);
|
||||
|
||||
if (!result.sound) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
if (!result.sound) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const currentChunk = result.chunk;
|
||||
const allChunks = result.chunks || chunks;
|
||||
const currentChunk = result.chunk;
|
||||
const allChunks = result.chunks || chunks;
|
||||
|
||||
// Set up playback status update
|
||||
result.sound.setOnPlaybackStatusUpdate((status) => {
|
||||
if (status.isLoaded) {
|
||||
// Calculate the actual position across all chunks
|
||||
const chunkPosition = status.positionMillis || 0;
|
||||
const overallPosition = currentChunk
|
||||
? currentChunk.start + chunkPosition
|
||||
: chunkPosition;
|
||||
// Set up playback status update
|
||||
result.sound.setOnPlaybackStatusUpdate((status) => {
|
||||
if (status.isLoaded) {
|
||||
// Calculate the actual position across all chunks
|
||||
const chunkPosition = status.positionMillis || 0;
|
||||
const overallPosition = currentChunk
|
||||
? currentChunk.start + chunkPosition
|
||||
: chunkPosition;
|
||||
|
||||
setAudioState((prev) => ({
|
||||
...prev,
|
||||
isPlaying: status.isPlaying,
|
||||
currentPosition: overallPosition,
|
||||
duration: totalDuration, // Keep using total duration
|
||||
}));
|
||||
setAudioState((prev) => ({
|
||||
...prev,
|
||||
isPlaying: status.isPlaying,
|
||||
currentPosition: overallPosition,
|
||||
duration: totalDuration, // Keep using total duration
|
||||
}));
|
||||
|
||||
// Update global store
|
||||
setIsPlaying(status.isPlaying);
|
||||
setCurrentPosition(overallPosition);
|
||||
}
|
||||
});
|
||||
// Update global store
|
||||
setIsPlaying(status.isPlaying);
|
||||
setCurrentPosition(overallPosition);
|
||||
}
|
||||
});
|
||||
|
||||
setAudioState((prev) => ({
|
||||
...prev,
|
||||
sound: result.sound,
|
||||
isLoading: false,
|
||||
isPlaying: true,
|
||||
duration: totalDuration, // Set total duration of all chunks
|
||||
currentChunk: currentChunk,
|
||||
}));
|
||||
setAudioState((prev) => ({
|
||||
...prev,
|
||||
sound: result.sound,
|
||||
isLoading: false,
|
||||
isPlaying: true,
|
||||
duration: totalDuration, // Set total duration of all chunks
|
||||
currentChunk: currentChunk,
|
||||
}));
|
||||
|
||||
setCurrentText(textId);
|
||||
setCurrentText(textId);
|
||||
|
||||
// Start playing
|
||||
await result.sound.playAsync();
|
||||
// Start playing
|
||||
await result.sound.playAsync();
|
||||
|
||||
// Apply saved playback rate
|
||||
if (audioState.playbackRate !== 1.0) {
|
||||
await result.sound.setRateAsync(audioState.playbackRate, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
setAudioState((prev) => ({ ...prev, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
audioState.sound,
|
||||
audioState.playbackRate,
|
||||
audioService,
|
||||
setCurrentText,
|
||||
setIsPlaying,
|
||||
setCurrentPosition,
|
||||
]
|
||||
);
|
||||
// Apply saved playback rate
|
||||
if (audioState.playbackRate !== 1.0) {
|
||||
await result.sound.setRateAsync(audioState.playbackRate, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error playing audio:', error);
|
||||
setAudioState((prev) => ({ ...prev, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
audioState.sound,
|
||||
audioState.playbackRate,
|
||||
audioService,
|
||||
setCurrentText,
|
||||
setIsPlaying,
|
||||
setCurrentPosition,
|
||||
]
|
||||
);
|
||||
|
||||
// Pause audio
|
||||
const pauseAudio = useCallback(async () => {
|
||||
if (audioState.sound) {
|
||||
await audioState.sound.pauseAsync();
|
||||
setAudioState((prev) => ({ ...prev, isPlaying: false }));
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [audioState.sound, setIsPlaying]);
|
||||
// Pause audio
|
||||
const pauseAudio = useCallback(async () => {
|
||||
if (audioState.sound) {
|
||||
await audioState.sound.pauseAsync();
|
||||
setAudioState((prev) => ({ ...prev, isPlaying: false }));
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [audioState.sound, setIsPlaying]);
|
||||
|
||||
// Resume audio
|
||||
const resumeAudio = useCallback(async () => {
|
||||
if (audioState.sound) {
|
||||
await audioState.sound.playAsync();
|
||||
setAudioState((prev) => ({ ...prev, isPlaying: true }));
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [audioState.sound, setIsPlaying]);
|
||||
// Resume audio
|
||||
const resumeAudio = useCallback(async () => {
|
||||
if (audioState.sound) {
|
||||
await audioState.sound.playAsync();
|
||||
setAudioState((prev) => ({ ...prev, isPlaying: true }));
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}, [audioState.sound, setIsPlaying]);
|
||||
|
||||
// Stop audio
|
||||
const stopAudio = useCallback(async () => {
|
||||
if (audioState.sound) {
|
||||
await audioState.sound.pauseAsync();
|
||||
await audioState.sound.unloadAsync();
|
||||
setAudioState((prev) => ({
|
||||
...prev,
|
||||
sound: undefined,
|
||||
isPlaying: false,
|
||||
currentPosition: 0,
|
||||
duration: 0,
|
||||
}));
|
||||
setCurrentText(null);
|
||||
setIsPlaying(false);
|
||||
setCurrentPosition(0);
|
||||
}
|
||||
}, [audioState.sound, setCurrentText, setIsPlaying, setCurrentPosition]);
|
||||
// Stop audio
|
||||
const stopAudio = useCallback(async () => {
|
||||
if (audioState.sound) {
|
||||
await audioState.sound.pauseAsync();
|
||||
await audioState.sound.unloadAsync();
|
||||
setAudioState((prev) => ({
|
||||
...prev,
|
||||
sound: undefined,
|
||||
isPlaying: false,
|
||||
currentPosition: 0,
|
||||
duration: 0,
|
||||
}));
|
||||
setCurrentText(null);
|
||||
setIsPlaying(false);
|
||||
setCurrentPosition(0);
|
||||
}
|
||||
}, [audioState.sound, setCurrentText, setIsPlaying, setCurrentPosition]);
|
||||
|
||||
// Seek to position
|
||||
const seekTo = useCallback(
|
||||
async (position: number) => {
|
||||
if (audioState.sound) {
|
||||
await audioState.sound.setPositionAsync(position);
|
||||
}
|
||||
},
|
||||
[audioState.sound]
|
||||
);
|
||||
// Seek to position
|
||||
const seekTo = useCallback(
|
||||
async (position: number) => {
|
||||
if (audioState.sound) {
|
||||
await audioState.sound.setPositionAsync(position);
|
||||
}
|
||||
},
|
||||
[audioState.sound]
|
||||
);
|
||||
|
||||
// Seek forward by seconds
|
||||
const seekForward = useCallback(
|
||||
async (seconds: number = 15) => {
|
||||
if (audioState.sound && audioState.duration > 0) {
|
||||
const newPosition = Math.min(
|
||||
audioState.currentPosition + seconds * 1000,
|
||||
audioState.duration
|
||||
);
|
||||
await audioState.sound.setPositionAsync(newPosition);
|
||||
}
|
||||
},
|
||||
[audioState.sound, audioState.currentPosition, audioState.duration]
|
||||
);
|
||||
// Seek forward by seconds
|
||||
const seekForward = useCallback(
|
||||
async (seconds: number = 15) => {
|
||||
if (audioState.sound && audioState.duration > 0) {
|
||||
const newPosition = Math.min(
|
||||
audioState.currentPosition + seconds * 1000,
|
||||
audioState.duration
|
||||
);
|
||||
await audioState.sound.setPositionAsync(newPosition);
|
||||
}
|
||||
},
|
||||
[audioState.sound, audioState.currentPosition, audioState.duration]
|
||||
);
|
||||
|
||||
// Seek backward by seconds
|
||||
const seekBackward = useCallback(
|
||||
async (seconds: number = 15) => {
|
||||
if (audioState.sound) {
|
||||
const newPosition = Math.max(audioState.currentPosition - seconds * 1000, 0);
|
||||
await audioState.sound.setPositionAsync(newPosition);
|
||||
}
|
||||
},
|
||||
[audioState.sound, audioState.currentPosition]
|
||||
);
|
||||
// Seek backward by seconds
|
||||
const seekBackward = useCallback(
|
||||
async (seconds: number = 15) => {
|
||||
if (audioState.sound) {
|
||||
const newPosition = Math.max(audioState.currentPosition - seconds * 1000, 0);
|
||||
await audioState.sound.setPositionAsync(newPosition);
|
||||
}
|
||||
},
|
||||
[audioState.sound, audioState.currentPosition]
|
||||
);
|
||||
|
||||
// Set playback speed
|
||||
const setPlaybackSpeed = useCallback(
|
||||
async (rate: number) => {
|
||||
if (audioState.sound) {
|
||||
try {
|
||||
await audioState.sound.setRateAsync(rate, true);
|
||||
setAudioState((prev) => ({ ...prev, playbackRate: rate }));
|
||||
// Persist to store
|
||||
updateSettings({ playbackRate: rate });
|
||||
} catch (error) {
|
||||
console.error('Error setting playback rate:', error);
|
||||
}
|
||||
} else {
|
||||
// If no sound is playing, just update the state for next playback
|
||||
setAudioState((prev) => ({ ...prev, playbackRate: rate }));
|
||||
// Persist to store
|
||||
updateSettings({ playbackRate: rate });
|
||||
}
|
||||
},
|
||||
[audioState.sound, updateSettings]
|
||||
);
|
||||
// Set playback speed
|
||||
const setPlaybackSpeed = useCallback(
|
||||
async (rate: number) => {
|
||||
if (audioState.sound) {
|
||||
try {
|
||||
await audioState.sound.setRateAsync(rate, true);
|
||||
setAudioState((prev) => ({ ...prev, playbackRate: rate }));
|
||||
// Persist to store
|
||||
updateSettings({ playbackRate: rate });
|
||||
} catch (error) {
|
||||
console.error('Error setting playback rate:', error);
|
||||
}
|
||||
} else {
|
||||
// If no sound is playing, just update the state for next playback
|
||||
setAudioState((prev) => ({ ...prev, playbackRate: rate }));
|
||||
// Persist to store
|
||||
updateSettings({ playbackRate: rate });
|
||||
}
|
||||
},
|
||||
[audioState.sound, updateSettings]
|
||||
);
|
||||
|
||||
// Clear audio cache
|
||||
const clearCache = useCallback(
|
||||
async (textId: string, chunks: AudioChunk[]) => {
|
||||
await audioService.clearAudioCache(textId, chunks);
|
||||
// Clear audio cache
|
||||
const clearCache = useCallback(
|
||||
async (textId: string, chunks: AudioChunk[]) => {
|
||||
await audioService.clearAudioCache(textId, chunks);
|
||||
|
||||
// Update text to mark as not cached
|
||||
await updateText(textId, {
|
||||
data: {
|
||||
audio: {
|
||||
hasLocalCache: false,
|
||||
chunks,
|
||||
totalSize: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[audioService, updateText]
|
||||
);
|
||||
// Update text to mark as not cached
|
||||
await updateText(textId, {
|
||||
data: {
|
||||
audio: {
|
||||
hasLocalCache: false,
|
||||
chunks,
|
||||
totalSize: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[audioService, updateText]
|
||||
);
|
||||
|
||||
// Get cache size
|
||||
const getCacheSize = useCallback(async () => {
|
||||
return await audioService.getCacheSize();
|
||||
}, [audioService]);
|
||||
// Get cache size
|
||||
const getCacheSize = useCallback(async () => {
|
||||
return await audioService.getCacheSize();
|
||||
}, [audioService]);
|
||||
|
||||
// Check if audio is cached
|
||||
const isAudioCached = useCallback(
|
||||
async (textId: string, chunks: AudioChunk[]) => {
|
||||
return await audioService.isAudioCached(textId, chunks);
|
||||
},
|
||||
[audioService]
|
||||
);
|
||||
// Check if audio is cached
|
||||
const isAudioCached = useCallback(
|
||||
async (textId: string, chunks: AudioChunk[]) => {
|
||||
return await audioService.isAudioCached(textId, chunks);
|
||||
},
|
||||
[audioService]
|
||||
);
|
||||
|
||||
return {
|
||||
audioState,
|
||||
generationProgress,
|
||||
downloadProgress,
|
||||
generateAudio,
|
||||
downloadAudio,
|
||||
playAudio,
|
||||
pauseAudio,
|
||||
resumeAudio,
|
||||
stopAudio,
|
||||
seekTo,
|
||||
seekForward,
|
||||
seekBackward,
|
||||
setPlaybackSpeed,
|
||||
clearCache,
|
||||
getCacheSize,
|
||||
isAudioCached,
|
||||
};
|
||||
return {
|
||||
audioState,
|
||||
generationProgress,
|
||||
downloadProgress,
|
||||
generateAudio,
|
||||
downloadAudio,
|
||||
playAudio,
|
||||
pauseAudio,
|
||||
resumeAudio,
|
||||
stopAudio,
|
||||
seekTo,
|
||||
seekForward,
|
||||
seekBackward,
|
||||
setPlaybackSpeed,
|
||||
clearCache,
|
||||
getCacheSize,
|
||||
isAudioCached,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,110 +4,110 @@ import { useStore } from '~/store/store';
|
|||
import { Session } from '@supabase/supabase-js';
|
||||
|
||||
export const useAuth = () => {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { setUser } = useStore();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { setUser } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial session
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
if (session?.user) {
|
||||
setUser({
|
||||
id: session.user.id,
|
||||
email: session.user.email!,
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
useEffect(() => {
|
||||
// Get initial session
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
if (session?.user) {
|
||||
setUser({
|
||||
id: session.user.id,
|
||||
email: session.user.email!,
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Listen for auth changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
if (session?.user) {
|
||||
setUser({
|
||||
id: session.user.id,
|
||||
email: session.user.email!,
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
});
|
||||
// Listen for auth changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
if (session?.user) {
|
||||
setUser({
|
||||
id: session.user.id,
|
||||
email: session.user.email!,
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [setUser]);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [setUser]);
|
||||
|
||||
const signUp = async (email: string, password: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
const signUp = async (email: string, password: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Fehler bei der Registrierung',
|
||||
};
|
||||
}
|
||||
};
|
||||
if (error) throw error;
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Fehler bei der Registrierung',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Anmelden',
|
||||
};
|
||||
}
|
||||
};
|
||||
if (error) throw error;
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Anmelden',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Abmelden',
|
||||
};
|
||||
}
|
||||
};
|
||||
const signOut = async () => {
|
||||
try {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Abmelden',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = async (email: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: 'reader://reset-password',
|
||||
});
|
||||
const resetPassword = async (email: string) => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: 'reader://reset-password',
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
|
||||
};
|
||||
}
|
||||
};
|
||||
if (error) throw error;
|
||||
return { data, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
loading,
|
||||
signUp,
|
||||
signIn,
|
||||
signOut,
|
||||
resetPassword,
|
||||
};
|
||||
return {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
loading,
|
||||
signUp,
|
||||
signIn,
|
||||
signOut,
|
||||
resetPassword,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,175 +3,175 @@ import { supabase } from '~/utils/supabase';
|
|||
import { Text, TextData } from '~/types/database';
|
||||
|
||||
export const useTexts = () => {
|
||||
const [texts, setTexts] = useState<Text[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [texts, setTexts] = useState<Text[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTexts();
|
||||
useEffect(() => {
|
||||
fetchTexts();
|
||||
|
||||
// Realtime Subscription
|
||||
const subscription = supabase
|
||||
.channel('texts_changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'texts',
|
||||
},
|
||||
(payload) => {
|
||||
if (payload.eventType === 'INSERT') {
|
||||
// Check if text already exists to avoid duplicates
|
||||
setTexts((prev) => {
|
||||
const exists = prev.some((text) => text.id === payload.new.id);
|
||||
if (exists) return prev;
|
||||
return [payload.new as Text, ...prev];
|
||||
});
|
||||
} else if (payload.eventType === 'UPDATE') {
|
||||
setTexts((prev) =>
|
||||
prev.map((text) => (text.id === payload.new.id ? (payload.new as Text) : text))
|
||||
);
|
||||
} else if (payload.eventType === 'DELETE') {
|
||||
setTexts((prev) => prev.filter((text) => text.id !== payload.old.id));
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
// Realtime Subscription
|
||||
const subscription = supabase
|
||||
.channel('texts_changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'texts',
|
||||
},
|
||||
(payload) => {
|
||||
if (payload.eventType === 'INSERT') {
|
||||
// Check if text already exists to avoid duplicates
|
||||
setTexts((prev) => {
|
||||
const exists = prev.some((text) => text.id === payload.new.id);
|
||||
if (exists) return prev;
|
||||
return [payload.new as Text, ...prev];
|
||||
});
|
||||
} else if (payload.eventType === 'UPDATE') {
|
||||
setTexts((prev) =>
|
||||
prev.map((text) => (text.id === payload.new.id ? (payload.new as Text) : text))
|
||||
);
|
||||
} else if (payload.eventType === 'DELETE') {
|
||||
setTexts((prev) => prev.filter((text) => text.id !== payload.old.id));
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchTexts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('texts')
|
||||
.select('*')
|
||||
.order('updated_at', { ascending: false });
|
||||
const fetchTexts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('texts')
|
||||
.select('*')
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setTexts(data || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (error) throw error;
|
||||
setTexts(data || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createText = async (title: string, content: string, initialData?: Partial<TextData>) => {
|
||||
try {
|
||||
// Get current user
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const createText = async (title: string, content: string, initialData?: Partial<TextData>) => {
|
||||
try {
|
||||
// Get current user
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Benutzer nicht eingeloggt');
|
||||
}
|
||||
if (!user) {
|
||||
throw new Error('Benutzer nicht eingeloggt');
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('texts')
|
||||
.insert({
|
||||
title,
|
||||
content,
|
||||
user_id: user.id, // Explicitly set user_id
|
||||
data: {
|
||||
tts: { speed: 1.0, voice: 'de-DE-Neural2-A' },
|
||||
tags: [],
|
||||
stats: { playCount: 0, totalTime: 0, completed: false },
|
||||
...initialData,
|
||||
},
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
const { data, error } = await supabase
|
||||
.from('texts')
|
||||
.insert({
|
||||
title,
|
||||
content,
|
||||
user_id: user.id, // Explicitly set user_id
|
||||
data: {
|
||||
tts: { speed: 1.0, voice: 'de-DE-Neural2-A' },
|
||||
tags: [],
|
||||
stats: { playCount: 0, totalTime: 0, completed: false },
|
||||
...initialData,
|
||||
},
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
if (error) throw error;
|
||||
|
||||
// Refresh the texts list to ensure we have the latest data
|
||||
await fetchTexts();
|
||||
// Refresh the texts list to ensure we have the latest data
|
||||
await fetchTexts();
|
||||
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
data: null,
|
||||
error: err instanceof Error ? err.message : 'Fehler beim Erstellen',
|
||||
};
|
||||
}
|
||||
};
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
data: null,
|
||||
error: err instanceof Error ? err.message : 'Fehler beim Erstellen',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateText = async (textId: string, updates: Partial<Text>) => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('texts')
|
||||
.update(updates)
|
||||
.eq('id', textId)
|
||||
.select()
|
||||
.single();
|
||||
const updateText = async (textId: string, updates: Partial<Text>) => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('texts')
|
||||
.update(updates)
|
||||
.eq('id', textId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
data: null,
|
||||
error: err instanceof Error ? err.message : 'Fehler beim Aktualisieren',
|
||||
};
|
||||
}
|
||||
};
|
||||
if (error) throw error;
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
data: null,
|
||||
error: err instanceof Error ? err.message : 'Fehler beim Aktualisieren',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const deleteText = async (textId: string) => {
|
||||
try {
|
||||
const { error } = await supabase.from('texts').delete().eq('id', textId);
|
||||
const deleteText = async (textId: string) => {
|
||||
try {
|
||||
const { error } = await supabase.from('texts').delete().eq('id', textId);
|
||||
|
||||
if (error) throw error;
|
||||
return { error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Fehler beim Löschen',
|
||||
};
|
||||
}
|
||||
};
|
||||
if (error) throw error;
|
||||
return { error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Fehler beim Löschen',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updatePosition = async (textId: string, position: number) => {
|
||||
const text = texts.find((t) => t.id === textId);
|
||||
if (!text) return { error: 'Text nicht gefunden' };
|
||||
const updatePosition = async (textId: string, position: number) => {
|
||||
const text = texts.find((t) => t.id === textId);
|
||||
if (!text) return { error: 'Text nicht gefunden' };
|
||||
|
||||
return updateText(textId, {
|
||||
data: {
|
||||
...text.data,
|
||||
tts: {
|
||||
...text.data.tts,
|
||||
lastPosition: position,
|
||||
lastPlayed: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
return updateText(textId, {
|
||||
data: {
|
||||
...text.data,
|
||||
tts: {
|
||||
...text.data.tts,
|
||||
lastPosition: position,
|
||||
lastPlayed: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getTextsByTag = (tag: string) => {
|
||||
return texts.filter((text) => text.data.tags?.includes(tag));
|
||||
};
|
||||
const getTextsByTag = (tag: string) => {
|
||||
return texts.filter((text) => text.data.tags?.includes(tag));
|
||||
};
|
||||
|
||||
const getAllTags = () => {
|
||||
const tagSet = new Set<string>();
|
||||
texts.forEach((text) => {
|
||||
text.data.tags?.forEach((tag) => tagSet.add(tag));
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
};
|
||||
const getAllTags = () => {
|
||||
const tagSet = new Set<string>();
|
||||
texts.forEach((text) => {
|
||||
text.data.tags?.forEach((tag) => tagSet.add(tag));
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
};
|
||||
|
||||
return {
|
||||
texts,
|
||||
loading,
|
||||
error,
|
||||
createText,
|
||||
updateText,
|
||||
deleteText,
|
||||
updatePosition,
|
||||
getTextsByTag,
|
||||
getAllTags,
|
||||
refetch: fetchTexts,
|
||||
};
|
||||
return {
|
||||
texts,
|
||||
loading,
|
||||
error,
|
||||
createText,
|
||||
updateText,
|
||||
deleteText,
|
||||
updatePosition,
|
||||
getTextsByTag,
|
||||
getAllTags,
|
||||
refetch: fetchTexts,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,179 +1,179 @@
|
|||
import { useStore } from '~/store/store';
|
||||
|
||||
export interface ThemeColors {
|
||||
// Background colors
|
||||
background: string;
|
||||
surface: string;
|
||||
surfaceSecondary: string;
|
||||
// Background colors
|
||||
background: string;
|
||||
surface: string;
|
||||
surfaceSecondary: string;
|
||||
|
||||
// Text colors
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
textTertiary: string;
|
||||
// Text colors
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
textTertiary: string;
|
||||
|
||||
// Border colors
|
||||
border: string;
|
||||
borderSecondary: string;
|
||||
// Border colors
|
||||
border: string;
|
||||
borderSecondary: string;
|
||||
|
||||
// Primary colors
|
||||
primary: string;
|
||||
primaryLight: string;
|
||||
primaryDark: string;
|
||||
// Primary colors
|
||||
primary: string;
|
||||
primaryLight: string;
|
||||
primaryDark: string;
|
||||
|
||||
// Status colors
|
||||
success: string;
|
||||
successLight: string;
|
||||
warning: string;
|
||||
warningLight: string;
|
||||
error: string;
|
||||
errorLight: string;
|
||||
// Status colors
|
||||
success: string;
|
||||
successLight: string;
|
||||
warning: string;
|
||||
warningLight: string;
|
||||
error: string;
|
||||
errorLight: string;
|
||||
|
||||
// Tab bar colors
|
||||
tabBarBackground: string;
|
||||
tabBarBorder: string;
|
||||
tabBarActive: string;
|
||||
tabBarInactive: string;
|
||||
// Tab bar colors
|
||||
tabBarBackground: string;
|
||||
tabBarBorder: string;
|
||||
tabBarActive: string;
|
||||
tabBarInactive: string;
|
||||
}
|
||||
|
||||
const lightTheme: ThemeColors = {
|
||||
// Background colors
|
||||
background: 'bg-gray-50',
|
||||
surface: 'bg-white',
|
||||
surfaceSecondary: 'bg-gray-100',
|
||||
// Background colors
|
||||
background: 'bg-gray-50',
|
||||
surface: 'bg-white',
|
||||
surfaceSecondary: 'bg-gray-100',
|
||||
|
||||
// Text colors
|
||||
text: 'text-gray-900',
|
||||
textSecondary: 'text-gray-600',
|
||||
textTertiary: 'text-gray-500',
|
||||
// Text colors
|
||||
text: 'text-gray-900',
|
||||
textSecondary: 'text-gray-600',
|
||||
textTertiary: 'text-gray-500',
|
||||
|
||||
// Border colors
|
||||
border: 'border-gray-200',
|
||||
borderSecondary: 'border-gray-300',
|
||||
// Border colors
|
||||
border: 'border-gray-200',
|
||||
borderSecondary: 'border-gray-300',
|
||||
|
||||
// Primary colors
|
||||
primary: 'bg-blue-600',
|
||||
primaryLight: 'bg-blue-50',
|
||||
primaryDark: 'bg-blue-700',
|
||||
// Primary colors
|
||||
primary: 'bg-blue-600',
|
||||
primaryLight: 'bg-blue-50',
|
||||
primaryDark: 'bg-blue-700',
|
||||
|
||||
// Status colors
|
||||
success: 'bg-green-600',
|
||||
successLight: 'bg-green-100',
|
||||
warning: 'bg-orange-600',
|
||||
warningLight: 'bg-orange-100',
|
||||
error: 'bg-red-600',
|
||||
errorLight: 'bg-red-50',
|
||||
// Status colors
|
||||
success: 'bg-green-600',
|
||||
successLight: 'bg-green-100',
|
||||
warning: 'bg-orange-600',
|
||||
warningLight: 'bg-orange-100',
|
||||
error: 'bg-red-600',
|
||||
errorLight: 'bg-red-50',
|
||||
|
||||
// Tab bar colors
|
||||
tabBarBackground: '#ffffff',
|
||||
tabBarBorder: '#e5e7eb',
|
||||
tabBarActive: '#3B82F6',
|
||||
tabBarInactive: '#6b7280',
|
||||
// Tab bar colors
|
||||
tabBarBackground: '#ffffff',
|
||||
tabBarBorder: '#e5e7eb',
|
||||
tabBarActive: '#3B82F6',
|
||||
tabBarInactive: '#6b7280',
|
||||
};
|
||||
|
||||
const darkTheme: ThemeColors = {
|
||||
// Background colors
|
||||
background: 'bg-gray-900',
|
||||
surface: 'bg-gray-800',
|
||||
surfaceSecondary: 'bg-gray-700',
|
||||
// Background colors
|
||||
background: 'bg-gray-900',
|
||||
surface: 'bg-gray-800',
|
||||
surfaceSecondary: 'bg-gray-700',
|
||||
|
||||
// Text colors
|
||||
text: 'text-white',
|
||||
textSecondary: 'text-gray-300',
|
||||
textTertiary: 'text-gray-400',
|
||||
// Text colors
|
||||
text: 'text-white',
|
||||
textSecondary: 'text-gray-300',
|
||||
textTertiary: 'text-gray-400',
|
||||
|
||||
// Border colors
|
||||
border: 'border-gray-600',
|
||||
borderSecondary: 'border-gray-500',
|
||||
// Border colors
|
||||
border: 'border-gray-600',
|
||||
borderSecondary: 'border-gray-500',
|
||||
|
||||
// Primary colors
|
||||
primary: 'bg-blue-600',
|
||||
primaryLight: 'bg-blue-900',
|
||||
primaryDark: 'bg-blue-700',
|
||||
// Primary colors
|
||||
primary: 'bg-blue-600',
|
||||
primaryLight: 'bg-blue-900',
|
||||
primaryDark: 'bg-blue-700',
|
||||
|
||||
// Status colors
|
||||
success: 'bg-green-600',
|
||||
successLight: 'bg-green-900',
|
||||
warning: 'bg-orange-600',
|
||||
warningLight: 'bg-orange-900',
|
||||
error: 'bg-red-600',
|
||||
errorLight: 'bg-red-900',
|
||||
// Status colors
|
||||
success: 'bg-green-600',
|
||||
successLight: 'bg-green-900',
|
||||
warning: 'bg-orange-600',
|
||||
warningLight: 'bg-orange-900',
|
||||
error: 'bg-red-600',
|
||||
errorLight: 'bg-red-900',
|
||||
|
||||
// Tab bar colors
|
||||
tabBarBackground: '#1f2937',
|
||||
tabBarBorder: '#374151',
|
||||
tabBarActive: '#3B82F6',
|
||||
tabBarInactive: '#9ca3af',
|
||||
// Tab bar colors
|
||||
tabBarBackground: '#1f2937',
|
||||
tabBarBorder: '#374151',
|
||||
tabBarActive: '#3B82F6',
|
||||
tabBarInactive: '#9ca3af',
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const { settings } = useStore();
|
||||
const isDark = settings.theme === 'dark';
|
||||
const { settings } = useStore();
|
||||
const isDark = settings.theme === 'dark';
|
||||
|
||||
const colors = isDark ? darkTheme : lightTheme;
|
||||
const colors = isDark ? darkTheme : lightTheme;
|
||||
|
||||
return {
|
||||
isDark,
|
||||
colors,
|
||||
theme: settings.theme,
|
||||
};
|
||||
return {
|
||||
isDark,
|
||||
colors,
|
||||
theme: settings.theme,
|
||||
};
|
||||
};
|
||||
|
||||
// Text color utilities
|
||||
export const useTextColors = () => {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
return {
|
||||
primary: colors.text,
|
||||
secondary: colors.textSecondary,
|
||||
tertiary: colors.textTertiary,
|
||||
primaryText: colors.text.replace('text-', 'text-'),
|
||||
secondaryText: colors.textSecondary.replace('text-', 'text-'),
|
||||
tertiaryText: colors.textTertiary.replace('text-', 'text-'),
|
||||
};
|
||||
return {
|
||||
primary: colors.text,
|
||||
secondary: colors.textSecondary,
|
||||
tertiary: colors.textTertiary,
|
||||
primaryText: colors.text.replace('text-', 'text-'),
|
||||
secondaryText: colors.textSecondary.replace('text-', 'text-'),
|
||||
tertiaryText: colors.textTertiary.replace('text-', 'text-'),
|
||||
};
|
||||
};
|
||||
|
||||
// Background color utilities
|
||||
export const useBackgroundColors = () => {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
return {
|
||||
main: colors.background,
|
||||
surface: colors.surface,
|
||||
surfaceSecondary: colors.surfaceSecondary,
|
||||
};
|
||||
return {
|
||||
main: colors.background,
|
||||
surface: colors.surface,
|
||||
surfaceSecondary: colors.surfaceSecondary,
|
||||
};
|
||||
};
|
||||
|
||||
// Border color utilities
|
||||
export const useBorderColors = () => {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
return {
|
||||
main: colors.border,
|
||||
secondary: colors.borderSecondary,
|
||||
};
|
||||
return {
|
||||
main: colors.border,
|
||||
secondary: colors.borderSecondary,
|
||||
};
|
||||
};
|
||||
|
||||
// Status color utilities
|
||||
export const useStatusColors = () => {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
return {
|
||||
success: colors.success,
|
||||
successLight: colors.successLight,
|
||||
warning: colors.warning,
|
||||
warningLight: colors.warningLight,
|
||||
error: colors.error,
|
||||
errorLight: colors.errorLight,
|
||||
};
|
||||
return {
|
||||
success: colors.success,
|
||||
successLight: colors.successLight,
|
||||
warning: colors.warning,
|
||||
warningLight: colors.warningLight,
|
||||
error: colors.error,
|
||||
errorLight: colors.errorLight,
|
||||
};
|
||||
};
|
||||
|
||||
// Primary color utilities
|
||||
export const usePrimaryColors = () => {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
return {
|
||||
main: colors.primary,
|
||||
light: colors.primaryLight,
|
||||
dark: colors.primaryDark,
|
||||
};
|
||||
return {
|
||||
main: colors.primary,
|
||||
light: colors.primaryLight,
|
||||
dark: colors.primaryDark,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,59 +1,59 @@
|
|||
{
|
||||
"name": "@reader/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@react-native-async-storage/async-storage": "^2.1.2",
|
||||
"@react-navigation/native": "^7.0.3",
|
||||
"@supabase/supabase-js": "^2.38.4",
|
||||
"expo": "^53.0.19",
|
||||
"expo-av": "^15.1.7",
|
||||
"expo-clipboard": "^7.1.5",
|
||||
"expo-constants": "~17.1.4",
|
||||
"expo-dev-client": "~5.2.4",
|
||||
"expo-dev-launcher": "^5.0.17",
|
||||
"expo-file-system": "^18.1.11",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-router": "~5.1.3",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "~5.0.6",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"nativewind": "latest",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.5",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-web": "^0.20.0",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.0.10",
|
||||
"ajv": "^8.12.0",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-expo": "^9.2.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
"name": "@reader/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@react-native-async-storage/async-storage": "^2.1.2",
|
||||
"@react-navigation/native": "^7.0.3",
|
||||
"@supabase/supabase-js": "^2.38.4",
|
||||
"expo": "^53.0.19",
|
||||
"expo-av": "^15.1.7",
|
||||
"expo-clipboard": "^7.1.5",
|
||||
"expo-constants": "~17.1.4",
|
||||
"expo-dev-client": "~5.2.4",
|
||||
"expo-dev-launcher": "^5.0.17",
|
||||
"expo-file-system": "^18.1.11",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-router": "~5.1.3",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "~5.0.6",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"nativewind": "latest",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.5",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-web": "^0.20.0",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.0.10",
|
||||
"ajv": "^8.12.0",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-expo": "^9.2.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,333 +7,333 @@ import { getVoiceById } from '~/constants/voices';
|
|||
const AUDIO_DIR = `${FileSystem.documentDirectory}audio/`;
|
||||
|
||||
export interface AudioGenerationProgress {
|
||||
chunksCompleted: number;
|
||||
totalChunks: number;
|
||||
currentChunk: string;
|
||||
isComplete: boolean;
|
||||
chunksCompleted: number;
|
||||
totalChunks: number;
|
||||
currentChunk: string;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
export class AudioService {
|
||||
private static instance: AudioService;
|
||||
private supabase = supabase;
|
||||
private static instance: AudioService;
|
||||
private supabase = supabase;
|
||||
|
||||
public static getInstance(): AudioService {
|
||||
if (!AudioService.instance) {
|
||||
AudioService.instance = new AudioService();
|
||||
}
|
||||
return AudioService.instance;
|
||||
}
|
||||
public static getInstance(): AudioService {
|
||||
if (!AudioService.instance) {
|
||||
AudioService.instance = new AudioService();
|
||||
}
|
||||
return AudioService.instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
this.initializeAudioDirectory();
|
||||
}
|
||||
private constructor() {
|
||||
this.initializeAudioDirectory();
|
||||
}
|
||||
|
||||
private async initializeAudioDirectory(): Promise<void> {
|
||||
try {
|
||||
await FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
}
|
||||
private async initializeAudioDirectory(): Promise<void> {
|
||||
try {
|
||||
await FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
}
|
||||
|
||||
// Generate audio for a text using Supabase Edge Function
|
||||
async generateAudioForText(
|
||||
textId: string,
|
||||
content: string,
|
||||
voice: string = 'de-DE',
|
||||
speed: number = 1.0,
|
||||
chunkSize: number = 1000,
|
||||
onProgress?: (progress: AudioGenerationProgress) => void,
|
||||
versionId?: string
|
||||
): Promise<{ success: boolean; error?: string; chunks?: AudioChunk[] }> {
|
||||
try {
|
||||
// Estimate number of chunks for progress tracking
|
||||
const estimatedChunks = Math.ceil(content.length / chunkSize);
|
||||
// Generate audio for a text using Supabase Edge Function
|
||||
async generateAudioForText(
|
||||
textId: string,
|
||||
content: string,
|
||||
voice: string = 'de-DE',
|
||||
speed: number = 1.0,
|
||||
chunkSize: number = 1000,
|
||||
onProgress?: (progress: AudioGenerationProgress) => void,
|
||||
versionId?: string
|
||||
): Promise<{ success: boolean; error?: string; chunks?: AudioChunk[] }> {
|
||||
try {
|
||||
// Estimate number of chunks for progress tracking
|
||||
const estimatedChunks = Math.ceil(content.length / chunkSize);
|
||||
|
||||
onProgress?.({
|
||||
chunksCompleted: 0,
|
||||
totalChunks: estimatedChunks,
|
||||
currentChunk: 'Starting generation...',
|
||||
isComplete: false,
|
||||
});
|
||||
onProgress?.({
|
||||
chunksCompleted: 0,
|
||||
totalChunks: estimatedChunks,
|
||||
currentChunk: 'Starting generation...',
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
// Determine which provider to use based on the voice
|
||||
let provider = 'google';
|
||||
// Determine which provider to use based on the voice
|
||||
let provider = 'google';
|
||||
|
||||
try {
|
||||
const voiceInfo = getVoiceById(voice);
|
||||
if (voiceInfo) {
|
||||
provider = voiceInfo.provider;
|
||||
} else {
|
||||
console.warn(`Voice not found: ${voice}, defaulting to Google provider`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting voice info:', error);
|
||||
// Continue with default Google provider
|
||||
}
|
||||
try {
|
||||
const voiceInfo = getVoiceById(voice);
|
||||
if (voiceInfo) {
|
||||
provider = voiceInfo.provider;
|
||||
} else {
|
||||
console.warn(`Voice not found: ${voice}, defaulting to Google provider`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting voice info:', error);
|
||||
// Continue with default Google provider
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('generate-audio', {
|
||||
body: {
|
||||
textId,
|
||||
content,
|
||||
voice,
|
||||
provider,
|
||||
speed,
|
||||
chunkSize,
|
||||
versionId,
|
||||
},
|
||||
});
|
||||
const { data, error } = await supabase.functions.invoke('generate-audio', {
|
||||
body: {
|
||||
textId,
|
||||
content,
|
||||
voice,
|
||||
provider,
|
||||
speed,
|
||||
chunkSize,
|
||||
versionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to generate audio');
|
||||
}
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to generate audio');
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
chunksCompleted: data.chunksGenerated,
|
||||
totalChunks: data.chunksGenerated,
|
||||
currentChunk: 'Audio generation complete!',
|
||||
isComplete: true,
|
||||
});
|
||||
onProgress?.({
|
||||
chunksCompleted: data.chunksGenerated,
|
||||
totalChunks: data.chunksGenerated,
|
||||
currentChunk: 'Audio generation complete!',
|
||||
isComplete: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chunks: data.chunks,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating audio:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
chunks: data.chunks,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating audio:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Download audio chunks to local storage
|
||||
async downloadAudioChunks(
|
||||
textId: string,
|
||||
chunks: AudioChunk[],
|
||||
onProgress?: (progress: { completed: number; total: number; currentChunk: string }) => void
|
||||
): Promise<{ success: boolean; error?: string; localChunks?: AudioChunk[] }> {
|
||||
try {
|
||||
const localChunks: AudioChunk[] = [];
|
||||
// Download audio chunks to local storage
|
||||
async downloadAudioChunks(
|
||||
textId: string,
|
||||
chunks: AudioChunk[],
|
||||
onProgress?: (progress: { completed: number; total: number; currentChunk: string }) => void
|
||||
): Promise<{ success: boolean; error?: string; localChunks?: AudioChunk[] }> {
|
||||
try {
|
||||
const localChunks: AudioChunk[] = [];
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
|
||||
onProgress?.({
|
||||
completed: i,
|
||||
total: chunks.length,
|
||||
currentChunk: chunk.id,
|
||||
});
|
||||
onProgress?.({
|
||||
completed: i,
|
||||
total: chunks.length,
|
||||
currentChunk: chunk.id,
|
||||
});
|
||||
|
||||
// Get signed URL for the chunk
|
||||
const { data: urlData, error: urlError } = await supabase.functions.invoke(
|
||||
'get-audio-url',
|
||||
{
|
||||
body: {
|
||||
textId,
|
||||
chunkId: chunk.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
// Get signed URL for the chunk
|
||||
const { data: urlData, error: urlError } = await supabase.functions.invoke(
|
||||
'get-audio-url',
|
||||
{
|
||||
body: {
|
||||
textId,
|
||||
chunkId: chunk.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (urlError || !urlData.success) {
|
||||
throw new Error(`Failed to get URL for chunk ${chunk.id}`);
|
||||
}
|
||||
if (urlError || !urlData.success) {
|
||||
throw new Error(`Failed to get URL for chunk ${chunk.id}`);
|
||||
}
|
||||
|
||||
// Download the audio file
|
||||
const localFilePath = `${AUDIO_DIR}${textId}_${chunk.id}.mp3`;
|
||||
const downloadResult = await FileSystem.downloadAsync(urlData.url, localFilePath);
|
||||
// Download the audio file
|
||||
const localFilePath = `${AUDIO_DIR}${textId}_${chunk.id}.mp3`;
|
||||
const downloadResult = await FileSystem.downloadAsync(urlData.url, localFilePath);
|
||||
|
||||
if (downloadResult.status !== 200) {
|
||||
throw new Error(`Failed to download chunk ${chunk.id}`);
|
||||
}
|
||||
if (downloadResult.status !== 200) {
|
||||
throw new Error(`Failed to download chunk ${chunk.id}`);
|
||||
}
|
||||
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(localFilePath);
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(localFilePath);
|
||||
|
||||
localChunks.push({
|
||||
...chunk,
|
||||
filename: `${textId}_${chunk.id}.mp3`,
|
||||
size: fileInfo.exists && 'size' in fileInfo ? fileInfo.size : chunk.size,
|
||||
});
|
||||
}
|
||||
localChunks.push({
|
||||
...chunk,
|
||||
filename: `${textId}_${chunk.id}.mp3`,
|
||||
size: fileInfo.exists && 'size' in fileInfo ? fileInfo.size : chunk.size,
|
||||
});
|
||||
}
|
||||
|
||||
onProgress?.({
|
||||
completed: chunks.length,
|
||||
total: chunks.length,
|
||||
currentChunk: 'Download complete!',
|
||||
});
|
||||
onProgress?.({
|
||||
completed: chunks.length,
|
||||
total: chunks.length,
|
||||
currentChunk: 'Download complete!',
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
localChunks,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error downloading audio chunks:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
localChunks,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error downloading audio chunks:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Play audio directly from Supabase Storage
|
||||
async playAudioFromSupabase(
|
||||
textId: string,
|
||||
chunks: AudioChunk[],
|
||||
startPosition: number = 0
|
||||
): Promise<{ sound?: Audio.Sound; chunk?: AudioChunk; chunks?: AudioChunk[]; error?: string }> {
|
||||
try {
|
||||
// Calculate chunk positions if not already set
|
||||
let currentPosition = 0;
|
||||
const chunksWithPositions = chunks.map((chunk) => {
|
||||
const chunkStart = currentPosition;
|
||||
const chunkEnd = currentPosition + chunk.duration * 1000; // Convert to milliseconds
|
||||
currentPosition = chunkEnd;
|
||||
return {
|
||||
...chunk,
|
||||
start: chunk.start ?? chunkStart,
|
||||
end: chunk.end ?? chunkEnd,
|
||||
};
|
||||
});
|
||||
// Play audio directly from Supabase Storage
|
||||
async playAudioFromSupabase(
|
||||
textId: string,
|
||||
chunks: AudioChunk[],
|
||||
startPosition: number = 0
|
||||
): Promise<{ sound?: Audio.Sound; chunk?: AudioChunk; chunks?: AudioChunk[]; error?: string }> {
|
||||
try {
|
||||
// Calculate chunk positions if not already set
|
||||
let currentPosition = 0;
|
||||
const chunksWithPositions = chunks.map((chunk) => {
|
||||
const chunkStart = currentPosition;
|
||||
const chunkEnd = currentPosition + chunk.duration * 1000; // Convert to milliseconds
|
||||
currentPosition = chunkEnd;
|
||||
return {
|
||||
...chunk,
|
||||
start: chunk.start ?? chunkStart,
|
||||
end: chunk.end ?? chunkEnd,
|
||||
};
|
||||
});
|
||||
|
||||
// Find the chunk that contains the start position
|
||||
const chunk =
|
||||
chunksWithPositions.find((c) => startPosition >= c.start && startPosition < c.end) ||
|
||||
chunksWithPositions[0]; // Default to first chunk if position not found
|
||||
// Find the chunk that contains the start position
|
||||
const chunk =
|
||||
chunksWithPositions.find((c) => startPosition >= c.start && startPosition < c.end) ||
|
||||
chunksWithPositions[0]; // Default to first chunk if position not found
|
||||
|
||||
if (!chunk) {
|
||||
throw new Error('No chunk found for the given position');
|
||||
}
|
||||
if (!chunk) {
|
||||
throw new Error('No chunk found for the given position');
|
||||
}
|
||||
|
||||
// Get signed URL for the audio chunk
|
||||
const { data: urlData, error: urlError } = await this.supabase.functions.invoke(
|
||||
'get-audio-url',
|
||||
{
|
||||
body: {
|
||||
textId,
|
||||
chunkId: chunk.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
// Get signed URL for the audio chunk
|
||||
const { data: urlData, error: urlError } = await this.supabase.functions.invoke(
|
||||
'get-audio-url',
|
||||
{
|
||||
body: {
|
||||
textId,
|
||||
chunkId: chunk.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (urlError || !urlData.success) {
|
||||
throw new Error(`Failed to get audio URL: ${urlError?.message || 'Unknown error'}`);
|
||||
}
|
||||
if (urlError || !urlData.success) {
|
||||
throw new Error(`Failed to get audio URL: ${urlError?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Create and load the audio from signed URL
|
||||
const { sound } = await Audio.Sound.createAsync({ uri: urlData.url });
|
||||
// Create and load the audio from signed URL
|
||||
const { sound } = await Audio.Sound.createAsync({ uri: urlData.url });
|
||||
|
||||
// Calculate position within the chunk
|
||||
const positionWithinChunk = Math.max(0, startPosition - chunk.start);
|
||||
await sound.setPositionAsync(positionWithinChunk);
|
||||
// Calculate position within the chunk
|
||||
const positionWithinChunk = Math.max(0, startPosition - chunk.start);
|
||||
await sound.setPositionAsync(positionWithinChunk);
|
||||
|
||||
return { sound, chunk, chunks: chunksWithPositions };
|
||||
} catch (error) {
|
||||
console.error('Error playing audio from Supabase:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
return { sound, chunk, chunks: chunksWithPositions };
|
||||
} catch (error) {
|
||||
console.error('Error playing audio from Supabase:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Play audio from local cache (kept for backward compatibility)
|
||||
async playAudioFromCache(
|
||||
textId: string,
|
||||
chunks: AudioChunk[],
|
||||
startPosition: number = 0
|
||||
): Promise<{ sound?: Audio.Sound; error?: string }> {
|
||||
try {
|
||||
// Find the chunk that contains the start position
|
||||
const chunk = chunks.find((c) => startPosition >= c.start && startPosition < c.end);
|
||||
// Play audio from local cache (kept for backward compatibility)
|
||||
async playAudioFromCache(
|
||||
textId: string,
|
||||
chunks: AudioChunk[],
|
||||
startPosition: number = 0
|
||||
): Promise<{ sound?: Audio.Sound; error?: string }> {
|
||||
try {
|
||||
// Find the chunk that contains the start position
|
||||
const chunk = chunks.find((c) => startPosition >= c.start && startPosition < c.end);
|
||||
|
||||
if (!chunk) {
|
||||
throw new Error('No chunk found for the given position');
|
||||
}
|
||||
if (!chunk) {
|
||||
throw new Error('No chunk found for the given position');
|
||||
}
|
||||
|
||||
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error('Audio file not found locally');
|
||||
}
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error('Audio file not found locally');
|
||||
}
|
||||
|
||||
// Create and load the audio
|
||||
const { sound } = await Audio.Sound.createAsync({ uri: filePath });
|
||||
// Create and load the audio
|
||||
const { sound } = await Audio.Sound.createAsync({ uri: filePath });
|
||||
|
||||
// Calculate position within the chunk
|
||||
const chunkProgress = (startPosition - chunk.start) / (chunk.end - chunk.start);
|
||||
const positionMillis = chunkProgress * chunk.duration * 1000;
|
||||
// Calculate position within the chunk
|
||||
const chunkProgress = (startPosition - chunk.start) / (chunk.end - chunk.start);
|
||||
const positionMillis = chunkProgress * chunk.duration * 1000;
|
||||
|
||||
await sound.setPositionAsync(positionMillis);
|
||||
await sound.setPositionAsync(positionMillis);
|
||||
|
||||
return { sound };
|
||||
} catch (error) {
|
||||
console.error('Error playing audio from cache:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
return { sound };
|
||||
} catch (error) {
|
||||
console.error('Error playing audio from cache:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Clear local audio cache for a text
|
||||
async clearAudioCache(textId: string, chunks: AudioChunk[]): Promise<void> {
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
||||
try {
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
} catch (deleteError) {
|
||||
console.log(`Could not delete ${chunk.filename}:`, deleteError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing audio cache:', error);
|
||||
}
|
||||
}
|
||||
// Clear local audio cache for a text
|
||||
async clearAudioCache(textId: string, chunks: AudioChunk[]): Promise<void> {
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
||||
try {
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
} catch (deleteError) {
|
||||
console.log(`Could not delete ${chunk.filename}:`, deleteError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing audio cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get total cache size
|
||||
async getCacheSize(): Promise<number> {
|
||||
try {
|
||||
const files = await FileSystem.readDirectoryAsync(AUDIO_DIR);
|
||||
let totalSize = 0;
|
||||
// Get total cache size
|
||||
async getCacheSize(): Promise<number> {
|
||||
try {
|
||||
const files = await FileSystem.readDirectoryAsync(AUDIO_DIR);
|
||||
let totalSize = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(`${AUDIO_DIR}${file}`);
|
||||
totalSize += fileInfo.exists && 'size' in fileInfo ? fileInfo.size : 0;
|
||||
}
|
||||
for (const file of files) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(`${AUDIO_DIR}${file}`);
|
||||
totalSize += fileInfo.exists && 'size' in fileInfo ? fileInfo.size : 0;
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
} catch (error) {
|
||||
console.error('Error calculating cache size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return totalSize;
|
||||
} catch (error) {
|
||||
console.error('Error calculating cache size:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if audio is cached locally
|
||||
async isAudioCached(textId: string, chunks: AudioChunk[]): Promise<boolean> {
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
// Check if audio is cached locally
|
||||
async isAudioCached(textId: string, chunks: AudioChunk[]): Promise<boolean> {
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
const filePath = `${AUDIO_DIR}${chunk.filename}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
|
||||
if (!fileInfo.exists) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!fileInfo.exists) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get file path for a chunk
|
||||
getChunkFilePath(textId: string, chunkId: string): string {
|
||||
return `${AUDIO_DIR}${textId}_${chunkId}.mp3`;
|
||||
}
|
||||
// Get file path for a chunk
|
||||
getChunkFilePath(textId: string, chunkId: string): string {
|
||||
return `${AUDIO_DIR}${textId}_${chunkId}.mp3`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,131 +1,131 @@
|
|||
import { supabase } from '~/utils/supabase';
|
||||
|
||||
export interface ExtractedContent {
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
source: string;
|
||||
domain: string;
|
||||
author: string;
|
||||
publishDate: string;
|
||||
wordCount: number;
|
||||
readingTime: number;
|
||||
tags: string[];
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt: string;
|
||||
source: string;
|
||||
domain: string;
|
||||
author: string;
|
||||
publishDate: string;
|
||||
wordCount: number;
|
||||
readingTime: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ExtractUrlError {
|
||||
message: string;
|
||||
code?: 'INVALID_URL' | 'FETCH_FAILED' | 'EXTRACTION_FAILED' | 'NETWORK_ERROR' | 'UNAUTHORIZED';
|
||||
message: string;
|
||||
code?: 'INVALID_URL' | 'FETCH_FAILED' | 'EXTRACTION_FAILED' | 'NETWORK_ERROR' | 'UNAUTHORIZED';
|
||||
}
|
||||
|
||||
class UrlExtractorService {
|
||||
async extractFromUrl(
|
||||
url: string
|
||||
): Promise<{ data: ExtractedContent | null; error: ExtractUrlError | null }> {
|
||||
try {
|
||||
// Basic URL validation
|
||||
const urlPattern = /^https?:\/\/.+/;
|
||||
if (!urlPattern.test(url)) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Bitte gib eine gültige URL ein (http:// oder https://)',
|
||||
code: 'INVALID_URL',
|
||||
},
|
||||
};
|
||||
}
|
||||
async extractFromUrl(
|
||||
url: string
|
||||
): Promise<{ data: ExtractedContent | null; error: ExtractUrlError | null }> {
|
||||
try {
|
||||
// Basic URL validation
|
||||
const urlPattern = /^https?:\/\/.+/;
|
||||
if (!urlPattern.test(url)) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Bitte gib eine gültige URL ein (http:// oder https://)',
|
||||
code: 'INVALID_URL',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('extract-url', {
|
||||
body: { url },
|
||||
});
|
||||
const { data, error } = await supabase.functions.invoke('extract-url', {
|
||||
body: { url },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error extracting URL:', error);
|
||||
if (error) {
|
||||
console.error('Error extracting URL:', error);
|
||||
|
||||
// Handle specific error cases
|
||||
if (error.message?.includes('Unauthorized')) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Nicht autorisiert. Bitte melde dich erneut an.',
|
||||
code: 'UNAUTHORIZED',
|
||||
},
|
||||
};
|
||||
}
|
||||
// Handle specific error cases
|
||||
if (error.message?.includes('Unauthorized')) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Nicht autorisiert. Bitte melde dich erneut an.',
|
||||
code: 'UNAUTHORIZED',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message?.includes('Failed to fetch URL')) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Die Webseite konnte nicht geladen werden. Überprüfe die URL.',
|
||||
code: 'FETCH_FAILED',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (error.message?.includes('Failed to fetch URL')) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message: 'Die Webseite konnte nicht geladen werden. Überprüfe die URL.',
|
||||
code: 'FETCH_FAILED',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message?.includes('Could not extract')) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message:
|
||||
'Der Text konnte nicht extrahiert werden. Die Seite ist möglicherweise nicht kompatibel.',
|
||||
code: 'EXTRACTION_FAILED',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (error.message?.includes('Could not extract')) {
|
||||
return {
|
||||
data: null,
|
||||
error: {
|
||||
message:
|
||||
'Der Text konnte nicht extrahiert werden. Die Seite ist möglicherweise nicht kompatibel.',
|
||||
code: 'EXTRACTION_FAILED',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: null,
|
||||
error: { message: error.message || 'Ein Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: null,
|
||||
error: { message: error.message || 'Ein Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
|
||||
};
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: 'Keine Daten empfangen', code: 'EXTRACTION_FAILED' },
|
||||
};
|
||||
}
|
||||
if (!data) {
|
||||
return {
|
||||
data: null,
|
||||
error: { message: 'Keine Daten empfangen', code: 'EXTRACTION_FAILED' },
|
||||
};
|
||||
}
|
||||
|
||||
return { data: data as ExtractedContent, error: null };
|
||||
} catch (error) {
|
||||
console.error('Unexpected error in extractFromUrl:', error);
|
||||
return {
|
||||
data: null,
|
||||
error: { message: 'Ein unerwarteter Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
|
||||
};
|
||||
}
|
||||
}
|
||||
return { data: data as ExtractedContent, error: null };
|
||||
} catch (error) {
|
||||
console.error('Unexpected error in extractFromUrl:', error);
|
||||
return {
|
||||
data: null,
|
||||
error: { message: 'Ein unerwarteter Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
validateUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return ['http:', 'https:'].includes(urlObj.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
validateUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return ['http:', 'https:'].includes(urlObj.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
formatExtractedContent(extracted: ExtractedContent): string {
|
||||
// Format the extracted content with title and metadata
|
||||
let formatted = extracted.title + '\n\n';
|
||||
formatExtractedContent(extracted: ExtractedContent): string {
|
||||
// Format the extracted content with title and metadata
|
||||
let formatted = extracted.title + '\n\n';
|
||||
|
||||
if (extracted.author) {
|
||||
formatted += `Von: ${extracted.author}\n`;
|
||||
}
|
||||
if (extracted.author) {
|
||||
formatted += `Von: ${extracted.author}\n`;
|
||||
}
|
||||
|
||||
if (extracted.publishDate) {
|
||||
formatted += `Veröffentlicht: ${extracted.publishDate}\n`;
|
||||
}
|
||||
if (extracted.publishDate) {
|
||||
formatted += `Veröffentlicht: ${extracted.publishDate}\n`;
|
||||
}
|
||||
|
||||
if (extracted.domain) {
|
||||
formatted += `Quelle: ${extracted.domain}\n`;
|
||||
}
|
||||
if (extracted.domain) {
|
||||
formatted += `Quelle: ${extracted.domain}\n`;
|
||||
}
|
||||
|
||||
formatted += '\n' + extracted.content;
|
||||
formatted += '\n' + extracted.content;
|
||||
|
||||
return formatted;
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
|
||||
export const urlExtractorService = new UrlExtractorService();
|
||||
|
|
|
|||
|
|
@ -3,82 +3,82 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// User
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
// User
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
|
||||
// Settings
|
||||
settings: {
|
||||
voice: string;
|
||||
speed: number;
|
||||
theme: 'light' | 'dark';
|
||||
playbackRate: number;
|
||||
};
|
||||
updateSettings: (settings: Partial<AppState['settings']>) => void;
|
||||
// Settings
|
||||
settings: {
|
||||
voice: string;
|
||||
speed: number;
|
||||
theme: 'light' | 'dark';
|
||||
playbackRate: number;
|
||||
};
|
||||
updateSettings: (settings: Partial<AppState['settings']>) => void;
|
||||
|
||||
// Audio Player
|
||||
currentTextId: string | null;
|
||||
isPlaying: boolean;
|
||||
currentPosition: number;
|
||||
setCurrentText: (textId: string | null) => void;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setCurrentPosition: (position: number) => void;
|
||||
// Audio Player
|
||||
currentTextId: string | null;
|
||||
isPlaying: boolean;
|
||||
currentPosition: number;
|
||||
setCurrentText: (textId: string | null) => void;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setCurrentPosition: (position: number) => void;
|
||||
|
||||
// UI State
|
||||
selectedTags: string[];
|
||||
toggleTag: (tag: string) => void;
|
||||
clearTags: () => void;
|
||||
// UI State
|
||||
selectedTags: string[];
|
||||
toggleTag: (tag: string) => void;
|
||||
clearTags: () => void;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// User
|
||||
user: null,
|
||||
setUser: (user) => set({ user }),
|
||||
persist(
|
||||
(set) => ({
|
||||
// User
|
||||
user: null,
|
||||
setUser: (user) => set({ user }),
|
||||
|
||||
// Settings
|
||||
settings: {
|
||||
voice: 'de-DE-Neural2-A',
|
||||
speed: 1.0,
|
||||
theme: 'light',
|
||||
playbackRate: 1.0,
|
||||
},
|
||||
updateSettings: (newSettings) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...newSettings },
|
||||
})),
|
||||
// Settings
|
||||
settings: {
|
||||
voice: 'de-DE-Neural2-A',
|
||||
speed: 1.0,
|
||||
theme: 'light',
|
||||
playbackRate: 1.0,
|
||||
},
|
||||
updateSettings: (newSettings) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...newSettings },
|
||||
})),
|
||||
|
||||
// Audio Player
|
||||
currentTextId: null,
|
||||
isPlaying: false,
|
||||
currentPosition: 0,
|
||||
setCurrentText: (textId) => set({ currentTextId: textId, currentPosition: 0 }),
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setCurrentPosition: (position) => set({ currentPosition: position }),
|
||||
// Audio Player
|
||||
currentTextId: null,
|
||||
isPlaying: false,
|
||||
currentPosition: 0,
|
||||
setCurrentText: (textId) => set({ currentTextId: textId, currentPosition: 0 }),
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setCurrentPosition: (position) => set({ currentPosition: position }),
|
||||
|
||||
// UI State
|
||||
selectedTags: [],
|
||||
toggleTag: (tag) =>
|
||||
set((state) => ({
|
||||
selectedTags: state.selectedTags.includes(tag)
|
||||
? state.selectedTags.filter((t) => t !== tag)
|
||||
: [...state.selectedTags, tag],
|
||||
})),
|
||||
clearTags: () => set({ selectedTags: [] }),
|
||||
}),
|
||||
{
|
||||
name: 'reader-storage',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
settings: state.settings,
|
||||
selectedTags: state.selectedTags,
|
||||
}),
|
||||
}
|
||||
)
|
||||
// UI State
|
||||
selectedTags: [],
|
||||
toggleTag: (tag) =>
|
||||
set((state) => ({
|
||||
selectedTags: state.selectedTags.includes(tag)
|
||||
? state.selectedTags.filter((t) => t !== tag)
|
||||
: [...state.selectedTags, tag],
|
||||
})),
|
||||
clearTags: () => set({ selectedTags: [] }),
|
||||
}),
|
||||
{
|
||||
name: 'reader-storage',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
settings: state.settings,
|
||||
selectedTags: state.selectedTags,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,88 +4,88 @@ import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
|
|||
import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'No authorization header' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'No authorization header' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: authHeader },
|
||||
},
|
||||
}
|
||||
);
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: authHeader },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabaseClient.auth.getUser();
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabaseClient.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { url } = await req.json();
|
||||
const { url } = await req.json();
|
||||
|
||||
if (!url) {
|
||||
return new Response(JSON.stringify({ error: 'URL is required' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!url) {
|
||||
return new Response(JSON.stringify({ error: 'URL is required' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
let validatedUrl;
|
||||
try {
|
||||
validatedUrl = new URL(url);
|
||||
if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// Validate URL
|
||||
let validatedUrl;
|
||||
try {
|
||||
validatedUrl = new URL(url);
|
||||
if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Use ScrapingBee API - requires API key in environment
|
||||
const scrapingBeeApiKey = Deno.env.get('SCRAPINGBEE_API_KEY');
|
||||
if (!scrapingBeeApiKey) {
|
||||
console.error('SCRAPINGBEE_API_KEY not configured, falling back to direct fetch');
|
||||
// Fallback to direct fetch if API key not configured
|
||||
return fallbackExtraction(validatedUrl, corsHeaders);
|
||||
}
|
||||
// Use ScrapingBee API - requires API key in environment
|
||||
const scrapingBeeApiKey = Deno.env.get('SCRAPINGBEE_API_KEY');
|
||||
if (!scrapingBeeApiKey) {
|
||||
console.error('SCRAPINGBEE_API_KEY not configured, falling back to direct fetch');
|
||||
// Fallback to direct fetch if API key not configured
|
||||
return fallbackExtraction(validatedUrl, corsHeaders);
|
||||
}
|
||||
|
||||
// ScrapingBee API request
|
||||
const scrapingBeeUrl = new URL('https://app.scrapingbee.com/api/v1/');
|
||||
scrapingBeeUrl.searchParams.append('api_key', scrapingBeeApiKey);
|
||||
scrapingBeeUrl.searchParams.append('url', validatedUrl.toString());
|
||||
scrapingBeeUrl.searchParams.append('render_js', 'true'); // Render JavaScript
|
||||
scrapingBeeUrl.searchParams.append('wait', '3000'); // Wait 3s for content to load
|
||||
scrapingBeeUrl.searchParams.append('block_ads', 'true'); // Block ads
|
||||
scrapingBeeUrl.searchParams.append('stealth_mode', 'true'); // Bypass anti-bot measures
|
||||
// ScrapingBee API request
|
||||
const scrapingBeeUrl = new URL('https://app.scrapingbee.com/api/v1/');
|
||||
scrapingBeeUrl.searchParams.append('api_key', scrapingBeeApiKey);
|
||||
scrapingBeeUrl.searchParams.append('url', validatedUrl.toString());
|
||||
scrapingBeeUrl.searchParams.append('render_js', 'true'); // Render JavaScript
|
||||
scrapingBeeUrl.searchParams.append('wait', '3000'); // Wait 3s for content to load
|
||||
scrapingBeeUrl.searchParams.append('block_ads', 'true'); // Block ads
|
||||
scrapingBeeUrl.searchParams.append('stealth_mode', 'true'); // Bypass anti-bot measures
|
||||
|
||||
// Custom JavaScript to remove cookie banners
|
||||
const jsScript = `
|
||||
// Custom JavaScript to remove cookie banners
|
||||
const jsScript = `
|
||||
// Remove cookie banners
|
||||
const selectors = [
|
||||
'[class*="cookie"]', '[id*="cookie"]',
|
||||
|
|
@ -111,189 +111,189 @@ serve(async (req) => {
|
|||
}
|
||||
});
|
||||
`;
|
||||
scrapingBeeUrl.searchParams.append('js_scenario', btoa(jsScript));
|
||||
scrapingBeeUrl.searchParams.append('js_scenario', btoa(jsScript));
|
||||
|
||||
const response = await fetch(scrapingBeeUrl.toString());
|
||||
const response = await fetch(scrapingBeeUrl.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('ScrapingBee error:', response.status, await response.text());
|
||||
return fallbackExtraction(validatedUrl, corsHeaders);
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error('ScrapingBee error:', response.status, await response.text());
|
||||
return fallbackExtraction(validatedUrl, corsHeaders);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const html = await response.text();
|
||||
|
||||
// Parse and extract content
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
// Parse and extract content
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
if (!doc) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!doc) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Use Readability to extract article content
|
||||
const reader = new Readability(doc);
|
||||
const article = reader.parse();
|
||||
// Use Readability to extract article content
|
||||
const reader = new Readability(doc);
|
||||
const article = reader.parse();
|
||||
|
||||
if (!article || article.textContent.length < 200) {
|
||||
// Try manual extraction
|
||||
return manualExtraction(doc, validatedUrl, corsHeaders);
|
||||
}
|
||||
if (!article || article.textContent.length < 200) {
|
||||
// Try manual extraction
|
||||
return manualExtraction(doc, validatedUrl, corsHeaders);
|
||||
}
|
||||
|
||||
// Extract metadata
|
||||
const metadata = extractMetadata(doc);
|
||||
const tags = generateTags(metadata.keywords);
|
||||
// Extract metadata
|
||||
const metadata = extractMetadata(doc);
|
||||
const tags = generateTags(metadata.keywords);
|
||||
|
||||
// Clean content
|
||||
const cleanedContent = article.textContent
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
// Clean content
|
||||
const cleanedContent = article.textContent
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: article.title || 'Untitled',
|
||||
content: cleanedContent,
|
||||
excerpt: article.excerpt || metadata.description || '',
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: article.byline || metadata.author || '',
|
||||
publishDate: metadata.publishDate || '',
|
||||
wordCount: cleanedContent.split(/\s+/).length,
|
||||
readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200),
|
||||
tags,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Extract URL error:', error);
|
||||
return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: article.title || 'Untitled',
|
||||
content: cleanedContent,
|
||||
excerpt: article.excerpt || metadata.description || '',
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: article.byline || metadata.author || '',
|
||||
publishDate: metadata.publishDate || '',
|
||||
wordCount: cleanedContent.split(/\s+/).length,
|
||||
readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200),
|
||||
tags,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Extract URL error:', error);
|
||||
return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
async function fallbackExtraction(url: URL, corsHeaders: any) {
|
||||
// Original extraction logic as fallback
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
});
|
||||
// Original extraction logic as fallback
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(JSON.stringify({ error: `Failed to fetch URL: ${response.status}` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
return new Response(JSON.stringify({ error: `Failed to fetch URL: ${response.status}` }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const html = await response.text();
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
return manualExtraction(doc!, url, corsHeaders);
|
||||
return manualExtraction(doc!, url, corsHeaders);
|
||||
}
|
||||
|
||||
function manualExtraction(doc: any, url: URL, corsHeaders: any) {
|
||||
let content = '';
|
||||
let title = '';
|
||||
let content = '';
|
||||
let title = '';
|
||||
|
||||
// Find title
|
||||
const titleElement = doc.querySelector('h1') || doc.querySelector('title');
|
||||
if (titleElement) {
|
||||
title = titleElement.textContent?.trim() || '';
|
||||
}
|
||||
// Find title
|
||||
const titleElement = doc.querySelector('h1') || doc.querySelector('title');
|
||||
if (titleElement) {
|
||||
title = titleElement.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
// Find content
|
||||
const contentSelectors = [
|
||||
'main',
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'.content',
|
||||
'#content',
|
||||
'.post',
|
||||
'.entry-content',
|
||||
'.article-content',
|
||||
];
|
||||
// Find content
|
||||
const contentSelectors = [
|
||||
'main',
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'.content',
|
||||
'#content',
|
||||
'.post',
|
||||
'.entry-content',
|
||||
'.article-content',
|
||||
];
|
||||
|
||||
for (const selector of contentSelectors) {
|
||||
const element = doc.querySelector(selector);
|
||||
if (element && element.textContent) {
|
||||
content = element.textContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const selector of contentSelectors) {
|
||||
const element = doc.querySelector(selector);
|
||||
if (element && element.textContent) {
|
||||
content = element.textContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get paragraphs
|
||||
if (!content || content.length < 200) {
|
||||
const paragraphs = doc.querySelectorAll('p');
|
||||
const texts: string[] = [];
|
||||
paragraphs.forEach((p: any) => {
|
||||
const text = p.textContent?.trim();
|
||||
if (text && text.length > 50) {
|
||||
texts.push(text);
|
||||
}
|
||||
});
|
||||
content = texts.join('\n\n');
|
||||
}
|
||||
// Get paragraphs
|
||||
if (!content || content.length < 200) {
|
||||
const paragraphs = doc.querySelectorAll('p');
|
||||
const texts: string[] = [];
|
||||
paragraphs.forEach((p: any) => {
|
||||
const text = p.textContent?.trim();
|
||||
if (text && text.length > 50) {
|
||||
texts.push(text);
|
||||
}
|
||||
});
|
||||
content = texts.join('\n\n');
|
||||
}
|
||||
|
||||
if (!content || content.length < 100) {
|
||||
return new Response(JSON.stringify({ error: 'Could not extract meaningful content' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!content || content.length < 100) {
|
||||
return new Response(JSON.stringify({ error: 'Could not extract meaningful content' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: title || 'Untitled',
|
||||
content: content,
|
||||
excerpt: content.substring(0, 200),
|
||||
source: url.toString(),
|
||||
domain: url.hostname,
|
||||
author: '',
|
||||
publishDate: '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
readingTime: Math.ceil(content.split(/\s+/).length / 200),
|
||||
tags: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: title || 'Untitled',
|
||||
content: content,
|
||||
excerpt: content.substring(0, 200),
|
||||
source: url.toString(),
|
||||
domain: url.hostname,
|
||||
author: '',
|
||||
publishDate: '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
readingTime: Math.ceil(content.split(/\s+/).length / 200),
|
||||
tags: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function extractMetadata(doc: any) {
|
||||
const metadata: Record<string, string> = {};
|
||||
const metaTags = doc.querySelectorAll('meta');
|
||||
const metadata: Record<string, string> = {};
|
||||
const metaTags = doc.querySelectorAll('meta');
|
||||
|
||||
metaTags.forEach((meta: any) => {
|
||||
const name = meta.getAttribute('name') || meta.getAttribute('property');
|
||||
const content = meta.getAttribute('content');
|
||||
metaTags.forEach((meta: any) => {
|
||||
const name = meta.getAttribute('name') || meta.getAttribute('property');
|
||||
const content = meta.getAttribute('content');
|
||||
|
||||
if (name && content) {
|
||||
if (name.includes('author')) metadata.author = content;
|
||||
if (name.includes('description')) metadata.description = content;
|
||||
if (name.includes('keywords')) metadata.keywords = content;
|
||||
if (name.includes('publish')) metadata.publishDate = content;
|
||||
}
|
||||
});
|
||||
if (name && content) {
|
||||
if (name.includes('author')) metadata.author = content;
|
||||
if (name.includes('description')) metadata.description = content;
|
||||
if (name.includes('keywords')) metadata.keywords = content;
|
||||
if (name.includes('publish')) metadata.publishDate = content;
|
||||
}
|
||||
});
|
||||
|
||||
return metadata;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function generateTags(keywords?: string): string[] {
|
||||
if (!keywords) return [];
|
||||
return keywords
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.slice(0, 5);
|
||||
if (!keywords) return [];
|
||||
return keywords
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,329 +4,329 @@ import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
|
|||
import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'No authorization header' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization');
|
||||
if (!authHeader) {
|
||||
return new Response(JSON.stringify({ error: 'No authorization header' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: authHeader },
|
||||
},
|
||||
}
|
||||
);
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: authHeader },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabaseClient.auth.getUser();
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabaseClient.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (authError || !user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { url } = await req.json();
|
||||
const { url } = await req.json();
|
||||
|
||||
if (!url) {
|
||||
return new Response(JSON.stringify({ error: 'URL is required' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!url) {
|
||||
return new Response(JSON.stringify({ error: 'URL is required' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
let validatedUrl;
|
||||
try {
|
||||
validatedUrl = new URL(url);
|
||||
if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// Validate URL
|
||||
let validatedUrl;
|
||||
try {
|
||||
validatedUrl = new URL(url);
|
||||
if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Try Jina.ai Reader API for better extraction
|
||||
try {
|
||||
const jinaUrl = `https://r.jina.ai/${validatedUrl.toString()}`;
|
||||
const jinaResponse = await fetch(jinaUrl, {
|
||||
headers: {
|
||||
Accept: 'text/plain',
|
||||
'X-Return-Format': 'text',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000), // 15 second timeout
|
||||
});
|
||||
// Try Jina.ai Reader API for better extraction
|
||||
try {
|
||||
const jinaUrl = `https://r.jina.ai/${validatedUrl.toString()}`;
|
||||
const jinaResponse = await fetch(jinaUrl, {
|
||||
headers: {
|
||||
Accept: 'text/plain',
|
||||
'X-Return-Format': 'text',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000), // 15 second timeout
|
||||
});
|
||||
|
||||
if (jinaResponse.ok) {
|
||||
const content = await jinaResponse.text();
|
||||
if (jinaResponse.ok) {
|
||||
const content = await jinaResponse.text();
|
||||
|
||||
// Check if we got meaningful content (not just cookie banner)
|
||||
if (
|
||||
content &&
|
||||
content.length > 500 &&
|
||||
!content.toLowerCase().includes('cookies zustimmen') &&
|
||||
!content.toLowerCase().includes('cookie banner')
|
||||
) {
|
||||
// Extract title from content (usually first line)
|
||||
const lines = content.split('\n').filter((line) => line.trim());
|
||||
const title = lines[0] || 'Untitled';
|
||||
const actualContent = lines.slice(1).join('\n\n');
|
||||
// Check if we got meaningful content (not just cookie banner)
|
||||
if (
|
||||
content &&
|
||||
content.length > 500 &&
|
||||
!content.toLowerCase().includes('cookies zustimmen') &&
|
||||
!content.toLowerCase().includes('cookie banner')
|
||||
) {
|
||||
// Extract title from content (usually first line)
|
||||
const lines = content.split('\n').filter((line) => line.trim());
|
||||
const title = lines[0] || 'Untitled';
|
||||
const actualContent = lines.slice(1).join('\n\n');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: title.substring(0, 200), // Limit title length
|
||||
content: actualContent || content,
|
||||
excerpt: actualContent.substring(0, 200),
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: '',
|
||||
publishDate: '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
readingTime: Math.ceil(content.split(/\s+/).length / 200),
|
||||
tags: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (jinaError) {
|
||||
console.log('Jina.ai extraction failed:', jinaError);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: title.substring(0, 200), // Limit title length
|
||||
content: actualContent || content,
|
||||
excerpt: actualContent.substring(0, 200),
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: '',
|
||||
publishDate: '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
readingTime: Math.ceil(content.split(/\s+/).length / 200),
|
||||
tags: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (jinaError) {
|
||||
console.log('Jina.ai extraction failed:', jinaError);
|
||||
}
|
||||
|
||||
// Fallback to direct webpage fetch
|
||||
const response = await fetch(validatedUrl.toString(), {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000), // 15 second timeout
|
||||
});
|
||||
// Fallback to direct webpage fetch
|
||||
const response = await fetch(validatedUrl.toString(), {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||
},
|
||||
signal: AbortSignal.timeout(15000), // 15 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Failed to fetch URL: ${response.status} ${response.statusText}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Failed to fetch URL: ${response.status} ${response.statusText}` }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML and extract content
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
// Parse HTML and extract content
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
if (!doc) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!doc) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Try to remove common cookie banners and overlays
|
||||
const elementsToRemove = [
|
||||
// Cookie banners
|
||||
'[class*="cookie"]',
|
||||
'[id*="cookie"]',
|
||||
'[class*="consent"]',
|
||||
'[id*="consent"]',
|
||||
'[class*="gdpr"]',
|
||||
'[id*="gdpr"]',
|
||||
'[class*="privacy"]',
|
||||
'[id*="privacy-banner"]',
|
||||
// Overlays
|
||||
'[class*="overlay"]',
|
||||
'[class*="modal"]',
|
||||
'[class*="popup"]',
|
||||
// Specific patterns
|
||||
'.cookie-banner',
|
||||
'#cookie-banner',
|
||||
'.privacy-banner',
|
||||
'#privacy-banner',
|
||||
];
|
||||
// Try to remove common cookie banners and overlays
|
||||
const elementsToRemove = [
|
||||
// Cookie banners
|
||||
'[class*="cookie"]',
|
||||
'[id*="cookie"]',
|
||||
'[class*="consent"]',
|
||||
'[id*="consent"]',
|
||||
'[class*="gdpr"]',
|
||||
'[id*="gdpr"]',
|
||||
'[class*="privacy"]',
|
||||
'[id*="privacy-banner"]',
|
||||
// Overlays
|
||||
'[class*="overlay"]',
|
||||
'[class*="modal"]',
|
||||
'[class*="popup"]',
|
||||
// Specific patterns
|
||||
'.cookie-banner',
|
||||
'#cookie-banner',
|
||||
'.privacy-banner',
|
||||
'#privacy-banner',
|
||||
];
|
||||
|
||||
elementsToRemove.forEach((selector) => {
|
||||
try {
|
||||
const elements = doc.querySelectorAll(selector);
|
||||
elements.forEach((el: any) => {
|
||||
// Only remove if it looks like a banner/overlay (not main content)
|
||||
const text = el.textContent || '';
|
||||
if (
|
||||
text.toLowerCase().includes('cookie') ||
|
||||
text.toLowerCase().includes('datenschutz') ||
|
||||
text.toLowerCase().includes('privacy') ||
|
||||
text.toLowerCase().includes('consent')
|
||||
) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore selector errors
|
||||
}
|
||||
});
|
||||
elementsToRemove.forEach((selector) => {
|
||||
try {
|
||||
const elements = doc.querySelectorAll(selector);
|
||||
elements.forEach((el: any) => {
|
||||
// Only remove if it looks like a banner/overlay (not main content)
|
||||
const text = el.textContent || '';
|
||||
if (
|
||||
text.toLowerCase().includes('cookie') ||
|
||||
text.toLowerCase().includes('datenschutz') ||
|
||||
text.toLowerCase().includes('privacy') ||
|
||||
text.toLowerCase().includes('consent')
|
||||
) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore selector errors
|
||||
}
|
||||
});
|
||||
|
||||
// Use Readability to extract article content
|
||||
const reader = new Readability(doc);
|
||||
const article = reader.parse();
|
||||
// Use Readability to extract article content
|
||||
const reader = new Readability(doc);
|
||||
const article = reader.parse();
|
||||
|
||||
if (!article) {
|
||||
// Fallback: Try to extract content manually
|
||||
let content = '';
|
||||
let title = '';
|
||||
if (!article) {
|
||||
// Fallback: Try to extract content manually
|
||||
let content = '';
|
||||
let title = '';
|
||||
|
||||
// Try to find title
|
||||
const titleElement = doc.querySelector('h1') || doc.querySelector('title');
|
||||
if (titleElement) {
|
||||
title = titleElement.textContent?.trim() || '';
|
||||
}
|
||||
// Try to find title
|
||||
const titleElement = doc.querySelector('h1') || doc.querySelector('title');
|
||||
if (titleElement) {
|
||||
title = titleElement.textContent?.trim() || '';
|
||||
}
|
||||
|
||||
// Try to find main content areas
|
||||
const contentSelectors = [
|
||||
'main',
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'.content',
|
||||
'#content',
|
||||
'.post',
|
||||
'.entry-content',
|
||||
'.article-content',
|
||||
'.main-content',
|
||||
];
|
||||
// Try to find main content areas
|
||||
const contentSelectors = [
|
||||
'main',
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'.content',
|
||||
'#content',
|
||||
'.post',
|
||||
'.entry-content',
|
||||
'.article-content',
|
||||
'.main-content',
|
||||
];
|
||||
|
||||
for (const selector of contentSelectors) {
|
||||
const element = doc.querySelector(selector);
|
||||
if (element && element.textContent) {
|
||||
content = element.textContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const selector of contentSelectors) {
|
||||
const element = doc.querySelector(selector);
|
||||
if (element && element.textContent) {
|
||||
content = element.textContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If still no content, get all paragraphs
|
||||
if (!content) {
|
||||
const paragraphs = doc.querySelectorAll('p');
|
||||
const texts: string[] = [];
|
||||
paragraphs.forEach((p: any) => {
|
||||
const text = p.textContent?.trim();
|
||||
if (text && text.length > 50) {
|
||||
// Filter out short paragraphs
|
||||
texts.push(text);
|
||||
}
|
||||
});
|
||||
content = texts.join('\n\n');
|
||||
}
|
||||
// If still no content, get all paragraphs
|
||||
if (!content) {
|
||||
const paragraphs = doc.querySelectorAll('p');
|
||||
const texts: string[] = [];
|
||||
paragraphs.forEach((p: any) => {
|
||||
const text = p.textContent?.trim();
|
||||
if (text && text.length > 50) {
|
||||
// Filter out short paragraphs
|
||||
texts.push(text);
|
||||
}
|
||||
});
|
||||
content = texts.join('\n\n');
|
||||
}
|
||||
|
||||
if (!content || content.length < 100) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Could not extract meaningful article content' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!content || content.length < 100) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Could not extract meaningful article content' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Create a pseudo-article object
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: title || 'Untitled',
|
||||
content: content,
|
||||
excerpt: content.substring(0, 200),
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: '',
|
||||
publishDate: '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
readingTime: Math.ceil(content.split(/\s+/).length / 200),
|
||||
tags: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
// Create a pseudo-article object
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: title || 'Untitled',
|
||||
content: content,
|
||||
excerpt: content.substring(0, 200),
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: '',
|
||||
publishDate: '',
|
||||
wordCount: content.split(/\s+/).length,
|
||||
readingTime: Math.ceil(content.split(/\s+/).length / 200),
|
||||
tags: [],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Extract additional metadata
|
||||
const metaTags = doc.querySelectorAll('meta');
|
||||
const metadata: Record<string, string> = {};
|
||||
// Extract additional metadata
|
||||
const metaTags = doc.querySelectorAll('meta');
|
||||
const metadata: Record<string, string> = {};
|
||||
|
||||
metaTags.forEach((meta: any) => {
|
||||
const name = meta.getAttribute('name') || meta.getAttribute('property');
|
||||
const content = meta.getAttribute('content');
|
||||
metaTags.forEach((meta: any) => {
|
||||
const name = meta.getAttribute('name') || meta.getAttribute('property');
|
||||
const content = meta.getAttribute('content');
|
||||
|
||||
if (name && content) {
|
||||
if (name.includes('author')) metadata.author = content;
|
||||
if (name.includes('description')) metadata.description = content;
|
||||
if (name.includes('keywords')) metadata.keywords = content;
|
||||
if (name.includes('publish')) metadata.publishDate = content;
|
||||
}
|
||||
});
|
||||
if (name && content) {
|
||||
if (name.includes('author')) metadata.author = content;
|
||||
if (name.includes('description')) metadata.description = content;
|
||||
if (name.includes('keywords')) metadata.keywords = content;
|
||||
if (name.includes('publish')) metadata.publishDate = content;
|
||||
}
|
||||
});
|
||||
|
||||
// Generate tags from keywords if available
|
||||
const tags = metadata.keywords
|
||||
? metadata.keywords
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.slice(0, 5)
|
||||
: [];
|
||||
// Generate tags from keywords if available
|
||||
const tags = metadata.keywords
|
||||
? metadata.keywords
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.length > 0)
|
||||
.slice(0, 5)
|
||||
: [];
|
||||
|
||||
// Clean and format the extracted text
|
||||
const cleanedContent = article.textContent
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
// Clean and format the extracted text
|
||||
const cleanedContent = article.textContent
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: article.title || 'Untitled',
|
||||
content: cleanedContent,
|
||||
excerpt: article.excerpt || metadata.description || '',
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: article.byline || metadata.author || '',
|
||||
publishDate: metadata.publishDate || '',
|
||||
wordCount: cleanedContent.split(/\s+/).length,
|
||||
readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200), // Assuming 200 words per minute
|
||||
tags,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Extract URL error:', error);
|
||||
return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
title: article.title || 'Untitled',
|
||||
content: cleanedContent,
|
||||
excerpt: article.excerpt || metadata.description || '',
|
||||
source: validatedUrl.toString(),
|
||||
domain: validatedUrl.hostname,
|
||||
author: article.byline || metadata.author || '',
|
||||
publishDate: metadata.publishDate || '',
|
||||
wordCount: cleanedContent.split(/\s+/).length,
|
||||
readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200), // Assuming 200 words per minute
|
||||
tags,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Extract URL error:', error);
|
||||
return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,497 +2,511 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
|||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
interface AudioRequest {
|
||||
textId: string;
|
||||
content: string;
|
||||
voice: string;
|
||||
provider: 'google' | 'elevenlabs' | 'openai';
|
||||
speed: number;
|
||||
chunkSize?: number;
|
||||
versionId?: string;
|
||||
textId: string;
|
||||
content: string;
|
||||
voice: string;
|
||||
provider: 'google' | 'elevenlabs' | 'openai';
|
||||
speed: number;
|
||||
chunkSize?: number;
|
||||
versionId?: string;
|
||||
}
|
||||
|
||||
interface AudioChunk {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse request first to get provider
|
||||
const requestData: AudioRequest = await req.json();
|
||||
const { provider = 'google' } = requestData;
|
||||
try {
|
||||
// Parse request first to get provider
|
||||
const requestData: AudioRequest = await req.json();
|
||||
const { provider = 'google' } = requestData;
|
||||
|
||||
// Check required environment variables based on provider
|
||||
let apiKeyPresent = false;
|
||||
let missingKeyMessage = '';
|
||||
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
apiKeyPresent = !!Deno.env.get('GOOGLE_TTS_API_KEY');
|
||||
missingKeyMessage = 'Missing GOOGLE_TTS_API_KEY environment variable';
|
||||
break;
|
||||
case 'elevenlabs':
|
||||
apiKeyPresent = !!Deno.env.get('ELEVENLABS_API_KEY');
|
||||
missingKeyMessage = 'Missing ELEVENLABS_API_KEY environment variable';
|
||||
break;
|
||||
case 'openai':
|
||||
apiKeyPresent = !!Deno.env.get('OPENAI_API_KEY');
|
||||
missingKeyMessage = 'Missing OPENAI_API_KEY environment variable';
|
||||
break;
|
||||
}
|
||||
// Check required environment variables based on provider
|
||||
let apiKeyPresent = false;
|
||||
let missingKeyMessage = '';
|
||||
|
||||
if (!apiKeyPresent) {
|
||||
console.error(missingKeyMessage);
|
||||
return new Response(JSON.stringify({ error: 'TTS service not configured' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
apiKeyPresent = !!Deno.env.get('GOOGLE_TTS_API_KEY');
|
||||
missingKeyMessage = 'Missing GOOGLE_TTS_API_KEY environment variable';
|
||||
break;
|
||||
case 'elevenlabs':
|
||||
apiKeyPresent = !!Deno.env.get('ELEVENLABS_API_KEY');
|
||||
missingKeyMessage = 'Missing ELEVENLABS_API_KEY environment variable';
|
||||
break;
|
||||
case 'openai':
|
||||
apiKeyPresent = !!Deno.env.get('OPENAI_API_KEY');
|
||||
missingKeyMessage = 'Missing OPENAI_API_KEY environment variable';
|
||||
break;
|
||||
}
|
||||
|
||||
// Initialize Supabase client
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: req.headers.get('Authorization')! },
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!apiKeyPresent) {
|
||||
console.error(missingKeyMessage);
|
||||
return new Response(JSON.stringify({ error: 'TTS service not configured' }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get user from JWT token
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabaseClient.auth.getUser();
|
||||
// Initialize Supabase client
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: req.headers.get('Authorization')! },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// Get user from JWT token
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabaseClient.auth.getUser();
|
||||
|
||||
const {
|
||||
textId,
|
||||
content,
|
||||
voice,
|
||||
speed,
|
||||
chunkSize = 1000,
|
||||
versionId,
|
||||
} = requestData;
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!textId || !content) {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const { textId, content, voice, speed, chunkSize = 1000, versionId } = requestData;
|
||||
|
||||
// Split text into chunks
|
||||
const chunks: AudioChunk[] = [];
|
||||
for (let i = 0; i < content.length; i += chunkSize) {
|
||||
chunks.push({
|
||||
id: `chunk-${chunks.length}`,
|
||||
start: i,
|
||||
end: Math.min(i + chunkSize, content.length),
|
||||
content: content.slice(i, Math.min(i + chunkSize, content.length)),
|
||||
});
|
||||
}
|
||||
// Validate input
|
||||
if (!textId || !content) {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Generate audio based on the provider
|
||||
let audioResult;
|
||||
|
||||
switch (provider) {
|
||||
case 'elevenlabs':
|
||||
audioResult = await generateElevenLabsTTS(chunks, voice, speed);
|
||||
break;
|
||||
case 'openai':
|
||||
audioResult = await generateOpenAITTS(chunks, voice, speed);
|
||||
break;
|
||||
case 'google':
|
||||
default:
|
||||
audioResult = await generateGoogleTTS(chunks, voice, speed);
|
||||
break;
|
||||
}
|
||||
// Split text into chunks
|
||||
const chunks: AudioChunk[] = [];
|
||||
for (let i = 0; i < content.length; i += chunkSize) {
|
||||
chunks.push({
|
||||
id: `chunk-${chunks.length}`,
|
||||
start: i,
|
||||
end: Math.min(i + chunkSize, content.length),
|
||||
content: content.slice(i, Math.min(i + chunkSize, content.length)),
|
||||
});
|
||||
}
|
||||
|
||||
const { audioChunks, totalSize } = audioResult;
|
||||
// Generate audio based on the provider
|
||||
let audioResult;
|
||||
|
||||
// Store audio chunks in Supabase Storage
|
||||
const storedChunks = [];
|
||||
for (const chunkData of audioChunks) {
|
||||
try {
|
||||
// Use versionId in path if provided, otherwise use default path
|
||||
const fileName = versionId
|
||||
? `${user.id}/${textId}/${versionId}/${chunkData.id}.mp3`
|
||||
: `${user.id}/${textId}/${chunkData.id}.mp3`;
|
||||
switch (provider) {
|
||||
case 'elevenlabs':
|
||||
audioResult = await generateElevenLabsTTS(chunks, voice, speed);
|
||||
break;
|
||||
case 'openai':
|
||||
audioResult = await generateOpenAITTS(chunks, voice, speed);
|
||||
break;
|
||||
case 'google':
|
||||
default:
|
||||
audioResult = await generateGoogleTTS(chunks, voice, speed);
|
||||
break;
|
||||
}
|
||||
|
||||
const { error: uploadError } = await supabaseClient.storage
|
||||
.from('audio')
|
||||
.upload(fileName, chunkData.audioBuffer, {
|
||||
contentType: 'audio/mpeg',
|
||||
upsert: true,
|
||||
});
|
||||
const { audioChunks, totalSize } = audioResult;
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Upload error:', uploadError);
|
||||
throw uploadError;
|
||||
}
|
||||
// Store audio chunks in Supabase Storage
|
||||
const storedChunks = [];
|
||||
for (const chunkData of audioChunks) {
|
||||
try {
|
||||
// Use versionId in path if provided, otherwise use default path
|
||||
const fileName = versionId
|
||||
? `${user.id}/${textId}/${versionId}/${chunkData.id}.mp3`
|
||||
: `${user.id}/${textId}/${chunkData.id}.mp3`;
|
||||
|
||||
// Create audio chunk metadata for storage
|
||||
storedChunks.push({
|
||||
id: chunkData.id,
|
||||
start: chunkData.start,
|
||||
end: chunkData.end,
|
||||
filename: fileName,
|
||||
size: chunkData.size,
|
||||
duration: chunkData.duration,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error storing chunk ${chunkData.id}:`, error);
|
||||
// Continue with other chunks, but log the error
|
||||
}
|
||||
}
|
||||
const { error: uploadError } = await supabaseClient.storage
|
||||
.from('audio')
|
||||
.upload(fileName, chunkData.audioBuffer, {
|
||||
contentType: 'audio/mpeg',
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
// Update text record with audio metadata
|
||||
const { error: updateError } = await supabaseClient
|
||||
.from('texts')
|
||||
.update({
|
||||
data: {
|
||||
audio: {
|
||||
hasLocalCache: false, // Will be set to true when downloaded to device
|
||||
chunks: storedChunks,
|
||||
totalSize,
|
||||
lastGenerated: new Date().toISOString(),
|
||||
settings: { voice, speed, provider },
|
||||
},
|
||||
},
|
||||
})
|
||||
.eq('id', textId)
|
||||
.eq('user_id', user.id);
|
||||
if (uploadError) {
|
||||
console.error('Upload error:', uploadError);
|
||||
throw uploadError;
|
||||
}
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
// Create audio chunk metadata for storage
|
||||
storedChunks.push({
|
||||
id: chunkData.id,
|
||||
start: chunkData.start,
|
||||
end: chunkData.end,
|
||||
filename: fileName,
|
||||
size: chunkData.size,
|
||||
duration: chunkData.duration,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error storing chunk ${chunkData.id}:`, error);
|
||||
// Continue with other chunks, but log the error
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
chunksGenerated: storedChunks.length,
|
||||
totalSize,
|
||||
chunks: storedChunks,
|
||||
provider,
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in generate-audio function:', error);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// Update text record with audio metadata
|
||||
const { error: updateError } = await supabaseClient
|
||||
.from('texts')
|
||||
.update({
|
||||
data: {
|
||||
audio: {
|
||||
hasLocalCache: false, // Will be set to true when downloaded to device
|
||||
chunks: storedChunks,
|
||||
totalSize,
|
||||
lastGenerated: new Date().toISOString(),
|
||||
settings: { voice, speed, provider },
|
||||
},
|
||||
},
|
||||
})
|
||||
.eq('id', textId)
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
chunksGenerated: storedChunks.length,
|
||||
totalSize,
|
||||
chunks: storedChunks,
|
||||
provider,
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in generate-audio function:', error);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function extractLanguageCode(voiceId: string): string {
|
||||
// Extract language code from voice ID (e.g., "de-DE" from "de-DE-Neural2-G")
|
||||
const parts = voiceId.split('-');
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}-${parts[1]}`;
|
||||
}
|
||||
return 'de-DE'; // Default fallback
|
||||
// Extract language code from voice ID (e.g., "de-DE" from "de-DE-Neural2-G")
|
||||
const parts = voiceId.split('-');
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}-${parts[1]}`;
|
||||
}
|
||||
return 'de-DE'; // Default fallback
|
||||
}
|
||||
|
||||
function getVoiceName(voiceId: string): string {
|
||||
// If it's already a full voice ID (contains more than just language code), return it
|
||||
if (voiceId.includes('-') && voiceId.split('-').length > 2) {
|
||||
return voiceId;
|
||||
}
|
||||
// If it's already a full voice ID (contains more than just language code), return it
|
||||
if (voiceId.includes('-') && voiceId.split('-').length > 2) {
|
||||
return voiceId;
|
||||
}
|
||||
|
||||
// Legacy support: map old language codes to default voices
|
||||
const legacyVoiceMap: Record<string, string> = {
|
||||
'de-DE': 'de-DE-Neural2-A',
|
||||
'en-US': 'en-US-Neural2-A',
|
||||
'en-GB': 'en-GB-Neural2-A',
|
||||
};
|
||||
// Legacy support: map old language codes to default voices
|
||||
const legacyVoiceMap: Record<string, string> = {
|
||||
'de-DE': 'de-DE-Neural2-A',
|
||||
'en-US': 'en-US-Neural2-A',
|
||||
'en-GB': 'en-GB-Neural2-A',
|
||||
};
|
||||
|
||||
return legacyVoiceMap[voiceId] || 'de-DE-Neural2-A';
|
||||
return legacyVoiceMap[voiceId] || 'de-DE-Neural2-A';
|
||||
}
|
||||
|
||||
function estimateAudioDuration(text: string, speed: number): number {
|
||||
// Rough estimate: 150 words per minute for normal speech
|
||||
const wordsPerMinute = 150 * speed;
|
||||
const wordCount = text.split(/\s+/).length;
|
||||
return Math.ceil((wordCount / wordsPerMinute) * 60);
|
||||
// Rough estimate: 150 words per minute for normal speech
|
||||
const wordsPerMinute = 150 * speed;
|
||||
const wordCount = text.split(/\s+/).length;
|
||||
return Math.ceil((wordCount / wordsPerMinute) * 60);
|
||||
}
|
||||
|
||||
// Google Cloud TTS Implementation
|
||||
async function generateGoogleTTS(chunks: AudioChunk[], voice: string, speed: number) {
|
||||
const googleApiKey = Deno.env.get('GOOGLE_TTS_API_KEY');
|
||||
if (!googleApiKey) {
|
||||
throw new Error('Google TTS API key not configured');
|
||||
}
|
||||
const googleApiKey = Deno.env.get('GOOGLE_TTS_API_KEY');
|
||||
if (!googleApiKey) {
|
||||
throw new Error('Google TTS API key not configured');
|
||||
}
|
||||
|
||||
const audioChunks = [];
|
||||
let totalSize = 0;
|
||||
const audioChunks = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
let delay = 1000; // Start with 1 second delay
|
||||
for (const chunk of chunks) {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
let delay = 1000; // Start with 1 second delay
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const ttsResponse = await fetch(
|
||||
`https://texttospeech.googleapis.com/v1/text:synthesize?key=${googleApiKey}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
input: { text: chunk.content },
|
||||
voice: {
|
||||
languageCode: extractLanguageCode(voice),
|
||||
name: getVoiceName(voice),
|
||||
},
|
||||
audioConfig: {
|
||||
audioEncoding: 'MP3',
|
||||
speakingRate: speed,
|
||||
pitch: 0,
|
||||
volumeGainDb: 0,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const ttsResponse = await fetch(
|
||||
`https://texttospeech.googleapis.com/v1/text:synthesize?key=${googleApiKey}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
input: { text: chunk.content },
|
||||
voice: {
|
||||
languageCode: extractLanguageCode(voice),
|
||||
name: getVoiceName(voice),
|
||||
},
|
||||
audioConfig: {
|
||||
audioEncoding: 'MP3',
|
||||
speakingRate: speed,
|
||||
pitch: 0,
|
||||
volumeGainDb: 0,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (ttsResponse.status === 429 || ttsResponse.status === 503) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
console.log(`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(`Google TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`);
|
||||
}
|
||||
}
|
||||
if (ttsResponse.status === 429 || ttsResponse.status === 503) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
console.log(
|
||||
`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Google TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
const errorBody = await ttsResponse.text();
|
||||
console.error('Google TTS API Error:', {
|
||||
status: ttsResponse.status,
|
||||
body: errorBody,
|
||||
});
|
||||
throw new Error(`Google TTS error: ${ttsResponse.status}`);
|
||||
}
|
||||
if (!ttsResponse.ok) {
|
||||
const errorBody = await ttsResponse.text();
|
||||
console.error('Google TTS API Error:', {
|
||||
status: ttsResponse.status,
|
||||
body: errorBody,
|
||||
});
|
||||
throw new Error(`Google TTS error: ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const ttsData = await ttsResponse.json();
|
||||
const audioContent = ttsData.audioContent;
|
||||
const audioBuffer = Uint8Array.from(atob(audioContent), (c) => c.charCodeAt(0));
|
||||
const audioSize = audioBuffer.length;
|
||||
const ttsData = await ttsResponse.json();
|
||||
const audioContent = ttsData.audioContent;
|
||||
const audioBuffer = Uint8Array.from(atob(audioContent), (c) => c.charCodeAt(0));
|
||||
const audioSize = audioBuffer.length;
|
||||
|
||||
totalSize += audioSize;
|
||||
audioChunks.push({
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
audioBuffer,
|
||||
size: audioSize,
|
||||
duration: estimateAudioDuration(chunk.content, speed),
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.error(`Error processing Google TTS chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`, error);
|
||||
if (retries >= maxRetries) {
|
||||
throw error; // Re-throw after all retries exhausted
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff for other errors too
|
||||
}
|
||||
}
|
||||
}
|
||||
totalSize += audioSize;
|
||||
audioChunks.push({
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
audioBuffer,
|
||||
size: audioSize,
|
||||
duration: estimateAudioDuration(chunk.content, speed),
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.error(
|
||||
`Error processing Google TTS chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
|
||||
error
|
||||
);
|
||||
if (retries >= maxRetries) {
|
||||
throw error; // Re-throw after all retries exhausted
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff for other errors too
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { audioChunks, totalSize };
|
||||
return { audioChunks, totalSize };
|
||||
}
|
||||
|
||||
// ElevenLabs TTS Implementation
|
||||
async function generateElevenLabsTTS(chunks: AudioChunk[], voice: string, speed: number) {
|
||||
const elevenLabsApiKey = Deno.env.get('ELEVENLABS_API_KEY');
|
||||
if (!elevenLabsApiKey) {
|
||||
throw new Error('ElevenLabs API key not configured');
|
||||
}
|
||||
const elevenLabsApiKey = Deno.env.get('ELEVENLABS_API_KEY');
|
||||
if (!elevenLabsApiKey) {
|
||||
throw new Error('ElevenLabs API key not configured');
|
||||
}
|
||||
|
||||
const audioChunks = [];
|
||||
let totalSize = 0;
|
||||
const audioChunks = [];
|
||||
let totalSize = 0;
|
||||
|
||||
// Map voice IDs to ElevenLabs voice IDs
|
||||
const voiceMapping: Record<string, string> = {
|
||||
eleven_multilingual_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel
|
||||
eleven_multilingual_v1: 'pNInz6obpgDQGcFmaJgB', // Adam
|
||||
eleven_turbo_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel Turbo
|
||||
eleven_monolingual_v1: '2EiwWnXFnvU5JabPnv8n', // Clyde
|
||||
};
|
||||
// Map voice IDs to ElevenLabs voice IDs
|
||||
const voiceMapping: Record<string, string> = {
|
||||
eleven_multilingual_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel
|
||||
eleven_multilingual_v1: 'pNInz6obpgDQGcFmaJgB', // Adam
|
||||
eleven_turbo_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel Turbo
|
||||
eleven_monolingual_v1: '2EiwWnXFnvU5JabPnv8n', // Clyde
|
||||
};
|
||||
|
||||
const elevenLabsVoiceId = voiceMapping[voice] || '21m00Tcm4TlvDq8ikWAM';
|
||||
const elevenLabsVoiceId = voiceMapping[voice] || '21m00Tcm4TlvDq8ikWAM';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
let delay = 1000; // Start with 1 second delay
|
||||
for (const chunk of chunks) {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
let delay = 1000; // Start with 1 second delay
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const ttsResponse = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${elevenLabsVoiceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xi-api-key': elevenLabsApiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: chunk.content,
|
||||
model_id: voice.includes('turbo') ? 'eleven_turbo_v2' : 'eleven_multilingual_v2',
|
||||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.5,
|
||||
style: 0.5,
|
||||
use_speaker_boost: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const ttsResponse = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${elevenLabsVoiceId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xi-api-key': elevenLabsApiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: chunk.content,
|
||||
model_id: voice.includes('turbo') ? 'eleven_turbo_v2' : 'eleven_multilingual_v2',
|
||||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.5,
|
||||
style: 0.5,
|
||||
use_speaker_boost: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (ttsResponse.status === 429 || ttsResponse.status === 503) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
console.log(`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(`ElevenLabs TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`);
|
||||
}
|
||||
}
|
||||
if (ttsResponse.status === 429 || ttsResponse.status === 503) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
console.log(
|
||||
`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(
|
||||
`ElevenLabs TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`ElevenLabs TTS error: ${ttsResponse.status}`);
|
||||
}
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`ElevenLabs TTS error: ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
|
||||
const audioSize = audioBuffer.length;
|
||||
const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
|
||||
const audioSize = audioBuffer.length;
|
||||
|
||||
totalSize += audioSize;
|
||||
audioChunks.push({
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
audioBuffer,
|
||||
size: audioSize,
|
||||
duration: estimateAudioDuration(chunk.content, speed),
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.error(`Error processing ElevenLabs chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`, error);
|
||||
if (retries >= maxRetries) {
|
||||
throw error; // Re-throw after all retries exhausted
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff for other errors too
|
||||
}
|
||||
}
|
||||
}
|
||||
totalSize += audioSize;
|
||||
audioChunks.push({
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
audioBuffer,
|
||||
size: audioSize,
|
||||
duration: estimateAudioDuration(chunk.content, speed),
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.error(
|
||||
`Error processing ElevenLabs chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
|
||||
error
|
||||
);
|
||||
if (retries >= maxRetries) {
|
||||
throw error; // Re-throw after all retries exhausted
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff for other errors too
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { audioChunks, totalSize };
|
||||
return { audioChunks, totalSize };
|
||||
}
|
||||
|
||||
// OpenAI TTS Implementation
|
||||
async function generateOpenAITTS(chunks: AudioChunk[], voice: string, speed: number) {
|
||||
const openaiApiKey = Deno.env.get('OPENAI_API_KEY');
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OpenAI API key not configured');
|
||||
}
|
||||
const openaiApiKey = Deno.env.get('OPENAI_API_KEY');
|
||||
if (!openaiApiKey) {
|
||||
throw new Error('OpenAI API key not configured');
|
||||
}
|
||||
|
||||
const audioChunks = [];
|
||||
let totalSize = 0;
|
||||
const audioChunks = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
let delay = 1000; // Start with 1 second delay
|
||||
for (const chunk of chunks) {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
let delay = 1000; // Start with 1 second delay
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const ttsResponse = await fetch('https://api.openai.com/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'tts-1-hd', // Using HD model for better quality
|
||||
input: chunk.content,
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
}),
|
||||
});
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
const ttsResponse = await fetch('https://api.openai.com/v1/audio/speech', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'tts-1-hd', // Using HD model for better quality
|
||||
input: chunk.content,
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
}),
|
||||
});
|
||||
|
||||
if (ttsResponse.status === 429) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
console.log(`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(`OpenAI TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`);
|
||||
}
|
||||
}
|
||||
if (ttsResponse.status === 429) {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
console.log(
|
||||
`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(
|
||||
`OpenAI TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`OpenAI TTS error: ${ttsResponse.status}`);
|
||||
}
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`OpenAI TTS error: ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
|
||||
const audioSize = audioBuffer.length;
|
||||
const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
|
||||
const audioSize = audioBuffer.length;
|
||||
|
||||
totalSize += audioSize;
|
||||
audioChunks.push({
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
audioBuffer,
|
||||
size: audioSize,
|
||||
duration: estimateAudioDuration(chunk.content, speed),
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.error(`Error processing OpenAI chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`, error);
|
||||
if (retries >= maxRetries) {
|
||||
throw error; // Re-throw after all retries exhausted
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff for other errors too
|
||||
}
|
||||
}
|
||||
}
|
||||
totalSize += audioSize;
|
||||
audioChunks.push({
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
audioBuffer,
|
||||
size: audioSize,
|
||||
duration: estimateAudioDuration(chunk.content, speed),
|
||||
});
|
||||
break; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.error(
|
||||
`Error processing OpenAI chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
|
||||
error
|
||||
);
|
||||
if (retries >= maxRetries) {
|
||||
throw error; // Re-throw after all retries exhausted
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff for other errors too
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { audioChunks, totalSize };
|
||||
return { audioChunks, totalSize };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,109 +2,109 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
|||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
interface AudioUrlRequest {
|
||||
textId: string;
|
||||
chunkId: string;
|
||||
textId: string;
|
||||
chunkId: string;
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
// Handle CORS preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize Supabase client
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: req.headers.get('Authorization')! },
|
||||
},
|
||||
}
|
||||
);
|
||||
try {
|
||||
// Initialize Supabase client
|
||||
const supabaseClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: req.headers.get('Authorization')! },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Get user from JWT token
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabaseClient.auth.getUser();
|
||||
// Get user from JWT token
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabaseClient.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { textId, chunkId }: AudioUrlRequest = await req.json();
|
||||
const { textId, chunkId }: AudioUrlRequest = await req.json();
|
||||
|
||||
// Validate input
|
||||
if (!textId || !chunkId) {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// Validate input
|
||||
if (!textId || !chunkId) {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify text belongs to user
|
||||
const { data: text, error: textError } = await supabaseClient
|
||||
.from('texts')
|
||||
.select('data')
|
||||
.eq('id', textId)
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
// Verify text belongs to user
|
||||
const { data: text, error: textError } = await supabaseClient
|
||||
.from('texts')
|
||||
.select('data')
|
||||
.eq('id', textId)
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (textError || !text) {
|
||||
return new Response(JSON.stringify({ error: 'Text not found' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (textError || !text) {
|
||||
return new Response(JSON.stringify({ error: 'Text not found' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Find the chunk
|
||||
const chunk = text.data.audio?.chunks?.find((c: any) => c.id === chunkId);
|
||||
if (!chunk) {
|
||||
return new Response(JSON.stringify({ error: 'Chunk not found' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// Find the chunk
|
||||
const chunk = text.data.audio?.chunks?.find((c: any) => c.id === chunkId);
|
||||
if (!chunk) {
|
||||
return new Response(JSON.stringify({ error: 'Chunk not found' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Generate signed URL for the audio file with user-specific path
|
||||
const filePath = `${user.id}/${textId}/${chunkId}.mp3`;
|
||||
const { data: urlData, error: urlError } = await supabaseClient.storage
|
||||
.from('audio')
|
||||
.createSignedUrl(filePath, 3600); // 1 hour expiration
|
||||
// Generate signed URL for the audio file with user-specific path
|
||||
const filePath = `${user.id}/${textId}/${chunkId}.mp3`;
|
||||
const { data: urlData, error: urlError } = await supabaseClient.storage
|
||||
.from('audio')
|
||||
.createSignedUrl(filePath, 3600); // 1 hour expiration
|
||||
|
||||
if (urlError) {
|
||||
throw urlError;
|
||||
}
|
||||
if (urlError) {
|
||||
throw urlError;
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
url: urlData.signedUrl,
|
||||
chunk: {
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
duration: chunk.duration,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in get-audio-url function:', error);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
url: urlData.signedUrl,
|
||||
chunk: {
|
||||
id: chunk.id,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
duration: chunk.duration,
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in get-audio-url function:', error);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}', './hooks/**/*.{js,ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}', './hooks/**/*.{js,ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,79 @@
|
|||
export interface Text {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
data: TextData;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
data: TextData;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TextData {
|
||||
// Vorlese-Einstellungen
|
||||
tts?: {
|
||||
speed: number;
|
||||
voice: string;
|
||||
lastPosition?: number;
|
||||
lastPlayed?: string;
|
||||
};
|
||||
// Vorlese-Einstellungen
|
||||
tts?: {
|
||||
speed: number;
|
||||
voice: string;
|
||||
lastPosition?: number;
|
||||
lastPlayed?: string;
|
||||
};
|
||||
|
||||
// Legacy Audio-Cache (für Abwärtskompatibilität)
|
||||
audio?: {
|
||||
hasLocalCache: boolean;
|
||||
chunks: AudioChunk[];
|
||||
totalSize: number;
|
||||
lastGenerated?: string;
|
||||
settings?: {
|
||||
voice: string;
|
||||
speed: number;
|
||||
};
|
||||
};
|
||||
// Legacy Audio-Cache (für Abwärtskompatibilität)
|
||||
audio?: {
|
||||
hasLocalCache: boolean;
|
||||
chunks: AudioChunk[];
|
||||
totalSize: number;
|
||||
lastGenerated?: string;
|
||||
settings?: {
|
||||
voice: string;
|
||||
speed: number;
|
||||
};
|
||||
};
|
||||
|
||||
// Neue Audio-Versionen
|
||||
audioVersions?: AudioVersion[];
|
||||
currentAudioVersion?: string; // ID der aktiven Version
|
||||
// Neue Audio-Versionen
|
||||
audioVersions?: AudioVersion[];
|
||||
currentAudioVersion?: string; // ID der aktiven Version
|
||||
|
||||
// Organisation
|
||||
tags?: string[];
|
||||
color?: string;
|
||||
// Organisation
|
||||
tags?: string[];
|
||||
color?: string;
|
||||
|
||||
// Statistiken
|
||||
stats?: {
|
||||
playCount: number;
|
||||
totalTime: number;
|
||||
completed: boolean;
|
||||
};
|
||||
// Statistiken
|
||||
stats?: {
|
||||
playCount: number;
|
||||
totalTime: number;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
// Zusätzliche Felder
|
||||
notes?: string;
|
||||
source?: string;
|
||||
bookmarks?: Bookmark[];
|
||||
// Zusätzliche Felder
|
||||
notes?: string;
|
||||
source?: string;
|
||||
bookmarks?: Bookmark[];
|
||||
}
|
||||
|
||||
export interface AudioVersion {
|
||||
id: string; // z.B. "v1-1736979654989"
|
||||
chunks: AudioChunk[];
|
||||
settings: {
|
||||
voice: string;
|
||||
speed: number;
|
||||
};
|
||||
totalSize: number;
|
||||
hasLocalCache: boolean;
|
||||
createdAt: string;
|
||||
id: string; // z.B. "v1-1736979654989"
|
||||
chunks: AudioChunk[];
|
||||
settings: {
|
||||
voice: string;
|
||||
speed: number;
|
||||
};
|
||||
totalSize: number;
|
||||
hasLocalCache: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AudioChunk {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
filename: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
createdAt: string;
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
filename: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Bookmark {
|
||||
position: number;
|
||||
note?: string;
|
||||
created: string;
|
||||
position: number;
|
||||
note?: string;
|
||||
created: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,57 +4,57 @@ import { TextData, AudioVersion } from '~/types/database';
|
|||
* Migriert alte Audio-Daten zum neuen audioVersions Format
|
||||
*/
|
||||
export function migrateAudioData(data: TextData): TextData {
|
||||
// Wenn bereits audioVersions existiert, keine Migration nötig
|
||||
if (data.audioVersions && data.audioVersions.length > 0) {
|
||||
return data;
|
||||
}
|
||||
// Wenn bereits audioVersions existiert, keine Migration nötig
|
||||
if (data.audioVersions && data.audioVersions.length > 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Wenn alte audio Daten existieren, migriere sie
|
||||
if (data.audio && data.audio.chunks && data.audio.chunks.length > 0) {
|
||||
const versionId = `v1-${data.audio.lastGenerated ? new Date(data.audio.lastGenerated).getTime() : Date.now()}`;
|
||||
const audioVersion: AudioVersion = {
|
||||
id: versionId,
|
||||
chunks: data.audio.chunks,
|
||||
settings: data.audio.settings || {
|
||||
voice: data.tts?.voice || 'de-DE-Neural2-A',
|
||||
speed: data.tts?.speed || 1,
|
||||
},
|
||||
totalSize: data.audio.totalSize,
|
||||
hasLocalCache: data.audio.hasLocalCache,
|
||||
createdAt: data.audio.lastGenerated || new Date().toISOString(),
|
||||
};
|
||||
// Wenn alte audio Daten existieren, migriere sie
|
||||
if (data.audio && data.audio.chunks && data.audio.chunks.length > 0) {
|
||||
const versionId = `v1-${data.audio.lastGenerated ? new Date(data.audio.lastGenerated).getTime() : Date.now()}`;
|
||||
const audioVersion: AudioVersion = {
|
||||
id: versionId,
|
||||
chunks: data.audio.chunks,
|
||||
settings: data.audio.settings || {
|
||||
voice: data.tts?.voice || 'de-DE-Neural2-A',
|
||||
speed: data.tts?.speed || 1,
|
||||
},
|
||||
totalSize: data.audio.totalSize,
|
||||
hasLocalCache: data.audio.hasLocalCache,
|
||||
createdAt: data.audio.lastGenerated || new Date().toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
...data,
|
||||
audioVersions: [audioVersion],
|
||||
currentAudioVersion: versionId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
audioVersions: [audioVersion],
|
||||
currentAudioVersion: versionId,
|
||||
};
|
||||
}
|
||||
|
||||
// Keine Audio-Daten vorhanden
|
||||
return data;
|
||||
// Keine Audio-Daten vorhanden
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die aktuelle Audio-Version basierend auf currentAudioVersion
|
||||
*/
|
||||
export function getCurrentAudioVersion(data: TextData): AudioVersion | null {
|
||||
if (!data.audioVersions || data.audioVersions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!data.audioVersions || data.audioVersions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data.currentAudioVersion) {
|
||||
const version = data.audioVersions.find((v) => v.id === data.currentAudioVersion);
|
||||
if (version) return version;
|
||||
}
|
||||
if (data.currentAudioVersion) {
|
||||
const version = data.audioVersions.find((v) => v.id === data.currentAudioVersion);
|
||||
if (version) return version;
|
||||
}
|
||||
|
||||
// Fallback: nimm die neueste Version
|
||||
return data.audioVersions[data.audioVersions.length - 1];
|
||||
// Fallback: nimm die neueste Version
|
||||
return data.audioVersions[data.audioVersions.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert eine neue Versions-ID
|
||||
*/
|
||||
export function generateVersionId(): string {
|
||||
return `v${Date.now()}`;
|
||||
return `v${Date.now()}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,27 +3,27 @@ import { Platform } from 'react-native';
|
|||
|
||||
// Platform-specific storage adapter for Supabase
|
||||
const createStorage = () => {
|
||||
// For web/SSR environments, use a no-op storage or localStorage
|
||||
if (Platform.OS === 'web') {
|
||||
// Check if we're in a browser environment
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
return {
|
||||
getItem: async (key: string) => window.localStorage.getItem(key),
|
||||
setItem: async (key: string, value: string) => window.localStorage.setItem(key, value),
|
||||
removeItem: async (key: string) => window.localStorage.removeItem(key),
|
||||
};
|
||||
} else {
|
||||
// SSR environment - return no-op storage
|
||||
return {
|
||||
getItem: async () => null,
|
||||
setItem: async () => {},
|
||||
removeItem: async () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
// For web/SSR environments, use a no-op storage or localStorage
|
||||
if (Platform.OS === 'web') {
|
||||
// Check if we're in a browser environment
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
return {
|
||||
getItem: async (key: string) => window.localStorage.getItem(key),
|
||||
setItem: async (key: string, value: string) => window.localStorage.setItem(key, value),
|
||||
removeItem: async (key: string) => window.localStorage.removeItem(key),
|
||||
};
|
||||
} else {
|
||||
// SSR environment - return no-op storage
|
||||
return {
|
||||
getItem: async () => null,
|
||||
setItem: async () => {},
|
||||
removeItem: async () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For native platforms, use AsyncStorage
|
||||
return AsyncStorage;
|
||||
// For native platforms, use AsyncStorage
|
||||
return AsyncStorage;
|
||||
};
|
||||
|
||||
export const storage = createStorage();
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
|||
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
storage: storage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
auth: {
|
||||
storage: storage,
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue