style: auto-format codebase with Prettier

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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