mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-27 14:37:43 +02:00
chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace: - bauntown (community website) - maerchenzauber (AI story generation) - memoro (voice memo app) - news (news aggregation) - nutriphi (nutrition tracking) - reader (reading app) - uload (URL shortener) - wisekeep (AI wisdom extraction) Update CLAUDE.md documentation: - Add presi to active projects - Document archived projects section - Update workspace configuration Archived apps can be re-activated by moving back to apps/ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b97149ac12
commit
61d181fbc2
3148 changed files with 437 additions and 46640 deletions
16
apps-archived/reader/apps/mobile/app/(auth)/_layout.tsx
Normal file
16
apps-archived/reader/apps/mobile/app/(auth)/_layout.tsx
Normal file
|
|
@ -0,0 +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>
|
||||
);
|
||||
}
|
||||
122
apps-archived/reader/apps/mobile/app/(auth)/forgot-password.tsx
Normal file
122
apps-archived/reader/apps/mobile/app/(auth)/forgot-password.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
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 handleResetPassword = async () => {
|
||||
if (!email) {
|
||||
setError('Bitte gib deine E-Mail-Adresse ein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error } = await resetPassword(email);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
127
apps-archived/reader/apps/mobile/app/(auth)/login.tsx
Normal file
127
apps-archived/reader/apps/mobile/app/(auth)/login.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
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 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;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error } = await signIn(email, password);
|
||||
|
||||
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>
|
||||
|
||||
{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="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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
146
apps-archived/reader/apps/mobile/app/(auth)/register.tsx
Normal file
146
apps-archived/reader/apps/mobile/app/(auth)/register.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
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 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.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;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error } = await signUp(email, password);
|
||||
|
||||
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>
|
||||
|
||||
{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="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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
35
apps-archived/reader/apps/mobile/app/(tabs)/_layout.tsx
Normal file
35
apps-archived/reader/apps/mobile/app/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Tabs } from 'expo-router';
|
||||
import { TabBarIcon } from '../../components/TabBarIcon';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
342
apps-archived/reader/apps/mobile/app/(tabs)/index.tsx
Normal file
342
apps-archived/reader/apps/mobile/app/(tabs)/index.tsx
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Share,
|
||||
AppState,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { Stack, router, useFocusEffect } from 'expo-router';
|
||||
import { useTexts } from '~/hooks/useTexts';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
import { useStore } from '~/store/store';
|
||||
import { Text as TextType, AudioVersion } from '~/types/database';
|
||||
import { TagFilter } from '~/components/TagFilter';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
import { Header } from '~/components/Header';
|
||||
import { FloatingActionButton } from '~/components/FloatingActionButton';
|
||||
import { TextListItem } from '~/components/TextListItem';
|
||||
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);
|
||||
|
||||
// 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 when app becomes active
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (nextAppState === 'active') {
|
||||
checkClipboard();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 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`;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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 (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}
|
||||
/>
|
||||
);
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<Header title="Meine Texte" showBackButton={false} />
|
||||
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
215
apps-archived/reader/apps/mobile/app/(tabs)/two.tsx
Normal file
215
apps-archived/reader/apps/mobile/app/(tabs)/two.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Pressable, ScrollView } from 'react-native';
|
||||
import { Stack, router } from 'expo-router';
|
||||
import { useStore } from '~/store/store';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
import { useTexts } from '~/hooks/useTexts';
|
||||
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,
|
||||
} from '~/constants/voices';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
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';
|
||||
|
||||
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 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');
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<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}`}>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>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
apps-archived/reader/apps/mobile/app/+html.tsx
Normal file
46
apps-archived/reader/apps/mobile/app/+html.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// 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" />
|
||||
|
||||
{/*
|
||||
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"
|
||||
/>
|
||||
{/*
|
||||
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 />
|
||||
|
||||
{/* 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 = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
24
apps-archived/reader/apps/mobile/app/+not-found.tsx
Normal file
24
apps-archived/reader/apps/mobile/app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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]`,
|
||||
};
|
||||
35
apps-archived/reader/apps/mobile/app/_layout.tsx
Normal file
35
apps-archived/reader/apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Polyfill for structuredClone (not available in React Native 0.79.5)
|
||||
import '../global.css';
|
||||
|
||||
import { Stack, router } from 'expo-router';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
if (typeof globalThis.structuredClone === 'undefined') {
|
||||
globalThis.structuredClone = (obj: any) => JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
274
apps-archived/reader/apps/mobile/app/add-text.tsx
Normal file
274
apps-archived/reader/apps/mobile/app/add-text.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { Stack, router, useFocusEffect } from 'expo-router';
|
||||
import { useTexts } from '~/hooks/useTexts';
|
||||
import { Header } from '~/components/Header';
|
||||
import { useTheme } from '~/hooks/useTheme';
|
||||
import { useStore } from '~/store/store';
|
||||
import { Dropdown } from '~/components/dropdown';
|
||||
import { GERMAN_VOICES, QUALITY_LABELS, PROVIDER_LABELS, getVoiceById } from '~/constants/voices';
|
||||
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 handleExtractUrl = async () => {
|
||||
if (!url.trim()) {
|
||||
setError('Bitte gib eine URL ein');
|
||||
return;
|
||||
}
|
||||
|
||||
setExtracting(true);
|
||||
setError(null);
|
||||
|
||||
const { data, error: extractError } = await urlExtractorService.extractFromUrl(url);
|
||||
|
||||
setExtracting(false);
|
||||
|
||||
if (extractError) {
|
||||
setError(extractError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
setError('Bitte gib einen Text ein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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>
|
||||
}
|
||||
/>
|
||||
|
||||
<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}`}>
|
||||
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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
218
apps-archived/reader/apps/mobile/app/text/[id].tsx
Normal file
218
apps-archived/reader/apps/mobile/app/text/[id].tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, ActivityIndicator, ScrollView, Alert, Pressable } from 'react-native';
|
||||
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
||||
import { useTexts } from '~/hooks/useTexts';
|
||||
import { Text as TextType } from '~/types/database';
|
||||
import { AudioPlayer } from '~/components/AudioPlayer';
|
||||
import { Button } from '~/components/Button';
|
||||
import { Text } from '~/components/Text';
|
||||
import { Header } from '~/components/Header';
|
||||
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();
|
||||
|
||||
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 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 (!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>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
|
||||
<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>
|
||||
|
||||
<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}
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue