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:
Till-JS 2025-11-29 07:03:59 +01:00
parent b97149ac12
commit 61d181fbc2
3148 changed files with 437 additions and 46640 deletions

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

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

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

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

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

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

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

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

View 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]`,
};

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

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

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