diff --git a/apps/reader/.gitignore b/apps/reader/.gitignore
new file mode 100644
index 000000000..f5f1c697b
--- /dev/null
+++ b/apps/reader/.gitignore
@@ -0,0 +1,40 @@
+# Dependencies
+node_modules/
+
+# Build outputs
+dist/
+web-build/
+
+# Expo
+.expo/
+expo-env.d.ts
+
+# React Native (generated via expo prebuild)
+ios/
+android/
+
+# Environment
+.env
+.env.local
+.env.production
+
+# Debug
+npm-debug.*
+*.log
+
+# Certificates & Keys
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+*.orig.*
+
+# Metro
+.metro-health-check*
+
+# IDE
+.idea/
+
+# OS
+.DS_Store
diff --git a/apps/reader/CLAUDE.md b/apps/reader/CLAUDE.md
new file mode 100644
index 000000000..cbe53dc14
--- /dev/null
+++ b/apps/reader/CLAUDE.md
@@ -0,0 +1,141 @@
+# CLAUDE.md - Reader
+
+This file provides guidance to Claude Code when working with the Reader project.
+
+## Project Overview
+
+Reader is a Text-to-Speech React Native application built with Expo that converts text to high-quality audio using Google Chirp voices. It stores audio locally for offline playback and syncs data across devices via Supabase.
+
+## Architecture
+
+```
+apps/reader/
+├── apps/
+│ └── mobile/ # Expo React Native App (@reader/mobile)
+│ ├── app/ # Expo Router navigation
+│ │ ├── (tabs)/ # Tab navigation screens
+│ │ ├── (auth)/ # Auth flow routes
+│ │ └── _layout.tsx
+│ ├── components/ # Reusable UI components
+│ ├── hooks/ # Custom React hooks
+│ ├── services/ # Business logic services
+│ ├── store/ # Zustand state management
+│ ├── types/ # TypeScript types
+│ ├── utils/ # Utilities (Supabase client, etc.)
+│ ├── assets/ # Images, fonts
+│ └── package.json # @reader/mobile
+├── packages/ # For future shared code
+├── CLAUDE.md # This file
+└── .gitignore
+```
+
+## Development Commands
+
+```bash
+# From monorepo root
+pnpm install
+
+# Start Reader mobile app
+pnpm reader:dev
+# Or directly
+pnpm dev:reader:mobile
+
+# From apps/reader/apps/mobile/
+pnpm dev # Start Expo dev server
+pnpm ios # Run on iOS simulator
+pnpm android # Run on Android emulator
+pnpm web # Run on web
+
+# Code quality
+pnpm lint # Run ESLint
+pnpm format # Format with Prettier
+
+# Build for production
+pnpm build:preview # Preview build
+pnpm build:prod # Production build
+```
+
+## Tech Stack
+
+| Component | Technology |
+|-----------|------------|
+| Framework | React Native 0.79.5 + Expo SDK 53 |
+| Navigation | Expo Router v5 (file-based) |
+| Styling | NativeWind (Tailwind CSS for RN) |
+| State | Zustand |
+| Backend | Supabase (PostgreSQL + Auth) |
+| Language | TypeScript |
+
+## Database Design
+
+Single `texts` table with JSONB field for flexibility:
+- Stores texts, metadata, tags, and reading progress
+- Audio files stored locally, paths tracked in DB
+- Designed for future expansion without migrations
+
+See `apps/mobile/ReadMe/MinimalDatabase.md` for details.
+
+## Key Implementation Patterns
+
+### Navigation (Expo Router)
+```tsx
+// File-based routing in apps/mobile/app/
+// (tabs)/ - Tab navigation screens
+// (auth)/ - Auth flow routes
+```
+
+### Styling (NativeWind)
+```tsx
+
+ Hello
+
+```
+
+### State Management (Zustand)
+```tsx
+import { useStore } from '~/store/store';
+const { state, actions } = useStore();
+```
+
+### Supabase Client
+```tsx
+// Client configured in apps/mobile/utils/supabase.ts
+import { supabase } from '~/utils/supabase';
+```
+
+### Path Alias
+Use `~/*` for absolute imports from mobile root:
+```tsx
+import { Button } from '~/components/Button';
+```
+
+## Environment Variables
+
+Create `apps/reader/apps/mobile/.env`:
+```bash
+EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
+EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
+```
+
+## Current Implementation Status
+
+- [x] Expo Router setup with tab navigation
+- [x] Supabase integration
+- [x] Zustand store (user state, settings, audio player)
+- [x] NativeWind styling
+- [x] User authentication (Login, Register, Forgot Password)
+- [x] Text management UI (List, Add, View, Delete)
+- [x] Settings screen
+- [x] Text-to-Speech with Google Cloud TTS
+- [x] Audio player with progress tracking
+- [x] Offline audio storage (Expo FileSystem)
+- [x] Tag system with filtering
+- [x] Supabase Edge Functions for audio generation
+- [x] Audio chunk system for large texts
+- [x] Local audio caching
+
+## Detailed Documentation
+
+- `apps/mobile/ReadMe/ProjectOverview.md` - Project vision (German)
+- `apps/mobile/ReadMe/MinimalDatabase.md` - Database design
+- `apps/mobile/docs/` - Additional documentation
diff --git a/apps/reader/apps/mobile/.mcp.json b/apps/reader/apps/mobile/.mcp.json
new file mode 100644
index 000000000..bb7cd28b7
--- /dev/null
+++ b/apps/reader/apps/mobile/.mcp.json
@@ -0,0 +1,16 @@
+{
+ "mcpServers": {
+ "supabase": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@supabase/mcp-server-supabase@latest",
+ "--read-only",
+ "--project-ref=tiecnhktvovcqsrnunko"
+ ],
+ "env": {
+ "SUPABASE_ACCESS_TOKEN": "sbp_2faafc5ad01cb28bd9e9bc00bad8e9629e839c44"
+ }
+ }
+ }
+}
diff --git a/apps/reader/apps/mobile/CONTEXT_MENU_SOLUTION.md b/apps/reader/apps/mobile/CONTEXT_MENU_SOLUTION.md
new file mode 100644
index 000000000..655eac0f0
--- /dev/null
+++ b/apps/reader/apps/mobile/CONTEXT_MENU_SOLUTION.md
@@ -0,0 +1,58 @@
+# Context Menu Solution
+
+I've fixed the "View config not found for component 'ContextMenu'" error by replacing the native `react-native-context-menu-view` with a cross-platform solution that works with Expo Go.
+
+## What was changed:
+
+1. **Removed the native dependency**: Uninstalled `react-native-context-menu-view` which requires native code and doesn't work with Expo Go.
+
+2. **Created two alternative solutions**:
+
+### Option 1: ActionMenu (Currently Active)
+- Located in `/components/ActionMenu.tsx`
+- Uses native ActionSheetIOS on iOS for a truly native experience
+- Custom modal implementation for Android that slides up from bottom
+- Triggered by long press on list items
+
+### Option 2: ContextMenu (Alternative)
+- Located in `/components/ContextMenu.tsx`
+- Custom modal-based context menu that appears near the pressed item
+- Works on both platforms with consistent behavior
+- More traditional context menu appearance
+
+## How it works:
+
+The ActionMenu component wraps your list items and provides a long-press gesture handler. When activated:
+- On iOS: Shows native ActionSheetIOS
+- On Android: Shows a custom bottom sheet modal
+
+## Usage:
+
+```tsx
+ {
+ // Handle selection
+ }}>
+
+
+```
+
+## Benefits:
+- Works with Expo Go (no development build required)
+- Native feel on iOS
+- Consistent experience across platforms
+- No native dependencies
+
+## If you need a development build:
+
+If you prefer to use the native context menu and are willing to use a development build:
+1. Re-install `react-native-context-menu-view`
+2. Run `npx expo prebuild`
+3. Run `npx expo run:ios` or `npx expo run:android`
+
+The current solution allows you to continue using Expo Go for development while providing a good user experience.
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/ReadMe/AudioPlayerImprovements.md b/apps/reader/apps/mobile/ReadMe/AudioPlayerImprovements.md
new file mode 100644
index 000000000..9593d4315
--- /dev/null
+++ b/apps/reader/apps/mobile/ReadMe/AudioPlayerImprovements.md
@@ -0,0 +1,78 @@
+⏺ AudioPlayer Verbesserungsplan
+
+1. Geschwindigkeitssteuerung
+
+- Playback-Speed Kontrolle
+ (0.5x, 0.75x, 1x, 1.25x, 1.5x,
+ 2x)
+- Geschwindigkeit bleibt für
+ alle Audios gespeichert
+- Kleiner Button neben den
+ Controls
+
+2. Visuelle Verbesserungen
+
+- Waveform-Visualisierung
+ während der Wiedergabe
+- Bessere Progress-Bar mit
+ Touch-Gesten zum Scrubben
+- Pulsierender Play-Button
+ während des Ladens
+
+3. Erweiterte Navigation
+
+- Kapitel-Support (bei
+ längeren Texten)
+- Lesezeichen setzen während
+ der Wiedergabe
+- Zu bestimmten Zeitstempeln
+ springen
+
+4. Sleep Timer
+
+- Timer zum automatischen
+ Stoppen (15, 30, 45, 60 Min)
+- Fade-Out am Ende
+- Visual Countdown
+
+5. Kontinuierliche Wiedergabe
+
+- Automatisch nächsten Text
+ abspielen
+- Queue-System für mehrere
+ Texte
+- Shuffle-Modus
+
+6. Mini-Player
+
+- Kompakter Player am unteren
+ Bildschirmrand
+- Bleibt beim Navigieren
+ sichtbar
+- Swipe-to-dismiss
+
+7. Offline-Optimierung
+
+- Download-Button für lokale
+ Speicherung
+- Download-Progress anzeigen
+- Cache-Management UI
+
+8. Statistiken & History
+
+- Listening History anzeigen
+- Fortschritt pro Text tracken
+- Gesamte Hörzeit
+
+9. Accessibility
+
+- VoiceOver Support verbessern
+- Größere Touch-Targets
+- Keyboard-Shortcuts (iPad)
+
+10. Performance
+
+- Preloading des nächsten
+ Chunks
+- Smooth Chunk-Übergänge
+- Background Audio optimieren
diff --git a/apps/reader/apps/mobile/ReadMe/ExpoUI.md b/apps/reader/apps/mobile/ReadMe/ExpoUI.md
new file mode 100644
index 000000000..8d551bba9
--- /dev/null
+++ b/apps/reader/apps/mobile/ReadMe/ExpoUI.md
@@ -0,0 +1,226 @@
+Expo UI
+
+A set of components that allow you to build UIs directly with SwiftUI and Jetpack Compose from React.
+
+Bundled version:
+~0.1.1-alpha.10
+This library is currently in alpha and will frequently experience breaking changes. It is not available in the Expo Go app – use development builds to try it out.
+@expo/ui is a set of native input components that allows you to build fully native interfaces with SwiftUI and Jetpack Compose. It aims to provide the commonly used features and components that a typical app will need.
+
+Installation
+Terminal
+
+Copy
+
+npx expo install @expo/ui
+If you are installing this in an existing React Native app, make sure to install expo in your project.
+
+Swift UI examples
+BottomSheet
+
+iOS
+
+Code
+
+BottomSheet component on iOS.
+Button
+
+iOS
+
+Code
+
+Button component on iOS.
+CircularProgress
+
+iOS
+
+Code
+
+CircularProgress component on iOS.
+ColorPicker
+
+iOS
+
+Code
+
+ColorPicker component on iOS.
+ContextMenu
+Note: Also known as DropdownMenu.
+
+iOS
+
+Code
+
+ContextMenu component on iOS.
+DateTimePicker (date)
+
+iOS
+
+Code
+
+DateTimePicker (date) component on iOS.
+DateTimePicker (time)
+
+iOS
+
+Code
+
+DateTimePicker (time) component on iOS.
+Gauge
+
+iOS
+
+Code
+
+Gauge component on iOS.
+LinearProgress
+
+iOS
+
+Code
+
+LinearProgress component on iOS.
+List
+
+iOS
+
+Code
+
+List component on iOS.
+Picker (segmented)
+
+iOS
+
+Code
+
+Picker component on iOS.
+Picker (wheel)
+
+iOS
+
+Code
+
+Picker component on iOS.
+Slider
+
+iOS
+
+Code
+
+Slider component on iOS.
+Switch (toggle)
+Note: Also known as Toggle.
+
+iOS
+
+Code
+
+Switch component on iOS.
+Switch (checkbox)
+
+iOS
+
+Code
+
+Picker component on iOS.
+TextInput
+
+iOS
+
+Code
+
+TextInput component on iOS.
+Jetpack Compose examples
+Button
+
+Android
+
+Code
+
+Button component on Android.
+CircularProgress
+
+Android
+
+Code
+
+CircularProgress component on Android.
+ContextMenu
+Note: Also known as DropdownMenu.
+
+Android
+
+Code
+
+ContextMenu component on Android.
+DateTimePicker (date)
+
+Android
+
+Code
+
+DateTimePicker component on Android.
+DateTimePicker (time)
+
+Android
+
+Code
+
+DateTimePicker (time) component on Android.
+LinearProgress
+
+Android
+
+Code
+
+LinearProgress component on Android.
+Picker (radio)
+
+Android
+
+Code
+
+Picker component (radio) on Android.
+Picker (segmented)
+
+Android
+
+Code
+
+Picker component on Android.
+Slider
+
+Android
+
+Code
+
+Slider component on Android.
+Switch (toggle)
+Note: Also known as Toggle.
+
+Android
+
+Code
+
+Switch component on Android.
+Switch (checkbox)
+
+Android
+
+Code
+
+Switch (checkbox variant) component on Android.
+TextInput
+
+Android
+
+Code
+
+TextInput component on Android.
+API
+Full documentation is not yet available. Use TypeScript types to explore the API.
+
+// Import from the SwiftUI package
+import { BottomSheet } from '@expo/ui/swift-ui';
+// Import from the Jetpack Compose package
+import { Button } from '@expo/ui/jetpack-compose';
diff --git a/apps/reader/apps/mobile/ReadMe/MinimalDatabase.md b/apps/reader/apps/mobile/ReadMe/MinimalDatabase.md
new file mode 100644
index 000000000..a566b8c70
--- /dev/null
+++ b/apps/reader/apps/mobile/ReadMe/MinimalDatabase.md
@@ -0,0 +1,570 @@
+# Absolut Minimalste Text-to-Speech Datenbank
+
+## Philosophie
+Eine einzige Tabelle für alles. JSONB macht's möglich. Keine Joins, keine Komplexität, nur pure Funktionalität.
+
+## Die Eine Tabelle
+
+```sql
+-- Die einzige Tabelle die du brauchst
+CREATE TABLE texts (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
+ user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
+
+ -- Der eigentliche Content
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+
+ -- ALLES andere in einem JSONB Feld
+ data JSONB DEFAULT '{}' NOT NULL,
+
+ -- Nur die absolut nötigen Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Ein Index für Performance
+CREATE INDEX idx_texts_user ON texts(user_id);
+CREATE INDEX idx_texts_data ON texts USING GIN (data);
+
+-- RLS aktivieren
+ALTER TABLE texts ENABLE ROW LEVEL SECURITY;
+
+-- Jeder sieht nur seine eigenen Texte
+CREATE POLICY "Own texts only" ON texts
+ FOR ALL USING (auth.uid() = user_id);
+
+-- Update Timestamp Trigger
+CREATE OR REPLACE FUNCTION update_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER update_texts_updated_at
+ BEFORE UPDATE ON texts
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at();
+```
+
+## Was kommt ins `data` JSONB Feld?
+
+```javascript
+// Beispiel eines vollständigen Text-Objekts
+{
+ id: "uuid-hier",
+ user_id: "user-uuid",
+ title: "Mein Buch",
+ content: "Der eigentliche Text...",
+ data: {
+ // Vorlese-Einstellungen
+ tts: {
+ speed: 1.0,
+ voice: "de-DE",
+ lastPosition: 1234, // Zeichen-Position
+ lastPlayed: "2024-01-15T10:30:00Z"
+ },
+
+ // Audio-Cache (NEU!)
+ audio: {
+ hasLocalCache: false,
+ chunks: [
+ {
+ id: "chunk-1",
+ start: 0,
+ end: 1000, // Zeichen-Position
+ filename: "text-uuid-chunk-1.mp3",
+ size: 245760, // Bytes
+ duration: 120, // Sekunden
+ createdAt: "2024-01-15T10:00:00Z"
+ }
+ ],
+ totalSize: 2457600, // Total in Bytes
+ lastGenerated: "2024-01-15T10:00:00Z",
+ settings: { // Settings bei Generierung
+ voice: "de-DE",
+ speed: 1.0
+ }
+ },
+
+ // Organisation (optional)
+ tags: ["roman", "favorit"],
+ color: "#FF5733",
+
+ // Statistiken (optional)
+ stats: {
+ playCount: 5,
+ totalTime: 3600, // Sekunden
+ completed: false
+ },
+
+ // Was auch immer du später brauchst
+ notes: "Für die Zugfahrt",
+ source: "kindle-import"
+ },
+ created_at: "2024-01-01T10:00:00Z",
+ updated_at: "2024-01-15T10:30:00Z"
+}
+```
+
+## Basis-Operationen
+
+### Text erstellen
+```javascript
+const { data, error } = await supabase
+ .from('texts')
+ .insert({
+ title: 'Mein Text',
+ content: 'Inhalt hier...',
+ data: {
+ tts: { speed: 1.0, voice: 'de-DE' },
+ tags: ['neu']
+ }
+ });
+```
+
+### Alle Texte holen
+```javascript
+const { data: texts } = await supabase
+ .from('texts')
+ .select('*')
+ .order('updated_at', { ascending: false });
+```
+
+### Nach Tags filtern
+```javascript
+const { data: filtered } = await supabase
+ .from('texts')
+ .select('*')
+ .contains('data', { tags: ['favorit'] });
+```
+
+### Leseposition updaten
+```javascript
+const { error } = await supabase
+ .from('texts')
+ .update({
+ data: {
+ ...currentData,
+ tts: {
+ ...currentData.tts,
+ lastPosition: 5678,
+ lastPlayed: new Date().toISOString()
+ }
+ }
+ })
+ .eq('id', textId);
+```
+
+### Statistiken hochzählen
+```sql
+-- Als Postgres Funktion für atomare Updates
+CREATE OR REPLACE FUNCTION increment_play_count(text_id UUID)
+RETURNS void AS $$
+BEGIN
+ UPDATE texts
+ SET data = jsonb_set(
+ jsonb_set(
+ data,
+ '{stats,playCount}',
+ to_jsonb(COALESCE((data->'stats'->>'playCount')::int, 0) + 1)
+ ),
+ '{tts,lastPlayed}',
+ to_jsonb(NOW())
+ )
+ WHERE id = text_id;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Aufruf
+SELECT increment_play_count('text-uuid-hier');
+```
+
+## Supabase Quickstart
+
+```bash
+# 1. Supabase CLI installieren
+npm install -g supabase
+
+# 2. Projekt initialisieren
+supabase init
+
+# 3. Migration erstellen
+supabase migration new create_texts_table
+
+# 4. SQL von oben in die Migration kopieren
+
+# 5. Migration ausführen
+supabase db push
+```
+
+## React Native Integration
+
+```javascript
+// hooks/useTexts.js
+import { useState, useEffect } from 'react';
+import { supabase } from '../lib/supabase';
+
+export const useTexts = () => {
+ const [texts, setTexts] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetchTexts();
+ }, []);
+
+ const fetchTexts = async () => {
+ const { data } = await supabase
+ .from('texts')
+ .select('*')
+ .order('updated_at', { ascending: false });
+
+ setTexts(data || []);
+ setLoading(false);
+ };
+
+ const createText = async (title, content) => {
+ const { data, error } = await supabase
+ .from('texts')
+ .insert({
+ title,
+ content,
+ data: { tts: { speed: 1.0, voice: 'de-DE' } }
+ })
+ .select()
+ .single();
+
+ if (data) {
+ setTexts([data, ...texts]);
+ }
+ return { data, error };
+ };
+
+ const updatePosition = async (textId, position) => {
+ const text = texts.find(t => t.id === textId);
+ if (!text) return;
+
+ await supabase
+ .from('texts')
+ .update({
+ data: {
+ ...text.data,
+ tts: {
+ ...text.data.tts,
+ lastPosition: position,
+ lastPlayed: new Date().toISOString()
+ }
+ }
+ })
+ .eq('id', textId);
+ };
+
+ return { texts, loading, createText, updatePosition, refetch: fetchTexts };
+};
+```
+
+## Audio-Cache Management
+
+```javascript
+// hooks/useAudioCache.js
+import * as FileSystem from 'expo-file-system';
+import * as Speech from 'expo-speech';
+import { Audio } from 'expo-av';
+import { supabase } from '../lib/supabase';
+
+const AUDIO_DIR = `${FileSystem.documentDirectory}audio/`;
+
+export const useAudioCache = () => {
+ // Verzeichnis erstellen beim Start
+ useEffect(() => {
+ FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true })
+ .catch(() => {}); // Ignorieren wenn bereits existiert
+ }, []);
+
+ // Text in Chunks aufteilen (z.B. alle 1000 Zeichen)
+ const chunkText = (text, chunkSize = 1000) => {
+ const chunks = [];
+ for (let i = 0; i < text.length; i += chunkSize) {
+ chunks.push({
+ id: `chunk-${chunks.length}`,
+ start: i,
+ end: Math.min(i + chunkSize, text.length),
+ content: text.slice(i, i + chunkSize)
+ });
+ }
+ return chunks;
+ };
+
+ // Audio für einen Chunk generieren und speichern
+ const generateAudioChunk = async (textId, chunk, settings) => {
+ const filename = `${textId}-${chunk.id}.mp3`;
+ const filePath = `${AUDIO_DIR}${filename}`;
+
+ // Option 1: Mit einer TTS API (z.B. Google Cloud TTS)
+ // const audioData = await callTTSAPI(chunk.content, settings);
+ // await FileSystem.writeAsStringAsync(filePath, audioData, {
+ // encoding: FileSystem.EncodingType.Base64
+ // });
+
+ // Option 2: Workaround mit expo-speech (keine direkte MP3 Generierung)
+ // Hinweis: expo-speech kann nicht direkt als Datei speichern
+ // Alternative: Web-API oder Cloud-Service nutzen
+
+ const fileInfo = await FileSystem.getInfoAsync(filePath);
+
+ return {
+ id: chunk.id,
+ start: chunk.start,
+ end: chunk.end,
+ filename,
+ size: fileInfo.size || 0,
+ duration: Math.ceil(chunk.content.length / 150) * 60, // Geschätzt
+ createdAt: new Date().toISOString()
+ };
+ };
+
+ // Alle Chunks für einen Text generieren
+ const generateAudioForText = async (textId, content, settings = {}) => {
+ const chunks = chunkText(content);
+ const audioChunks = [];
+
+ for (const chunk of chunks) {
+ const audioChunk = await generateAudioChunk(textId, chunk, settings);
+ audioChunks.push(audioChunk);
+ }
+
+ // Metadaten in Supabase updaten
+ await updateAudioMetadata(textId, audioChunks, settings);
+
+ return audioChunks;
+ };
+
+ // Audio-Metadaten in Supabase speichern
+ const updateAudioMetadata = async (textId, chunks, settings) => {
+ const totalSize = chunks.reduce((sum, chunk) => sum + chunk.size, 0);
+
+ const { data: currentText } = await supabase
+ .from('texts')
+ .select('data')
+ .eq('id', textId)
+ .single();
+
+ await supabase
+ .from('texts')
+ .update({
+ data: {
+ ...currentText.data,
+ audio: {
+ hasLocalCache: true,
+ chunks,
+ totalSize,
+ lastGenerated: new Date().toISOString(),
+ settings
+ }
+ }
+ })
+ .eq('id', textId);
+ };
+
+ // Audio abspielen
+ const playAudioFromCache = async (textId, startPosition = 0) => {
+ const { data: text } = await supabase
+ .from('texts')
+ .select('data')
+ .eq('id', textId)
+ .single();
+
+ if (!text?.data?.audio?.hasLocalCache) {
+ throw new Error('Kein Audio-Cache vorhanden');
+ }
+
+ // Richtigen Chunk finden
+ const chunk = text.data.audio.chunks.find(
+ c => startPosition >= c.start && startPosition < c.end
+ );
+
+ if (!chunk) return;
+
+ const filePath = `${AUDIO_DIR}${chunk.filename}`;
+ const { sound } = await Audio.Sound.createAsync({ uri: filePath });
+
+ // Position innerhalb des Chunks berechnen
+ const chunkPosition = startPosition - chunk.start;
+ const positionMillis = (chunkPosition / chunk.end) * chunk.duration * 1000;
+
+ await sound.setPositionAsync(positionMillis);
+ await sound.playAsync();
+
+ return sound;
+ };
+
+ // Cache löschen
+ const clearAudioCache = async (textId) => {
+ const { data: text } = await supabase
+ .from('texts')
+ .select('data')
+ .eq('id', textId)
+ .single();
+
+ if (text?.data?.audio?.chunks) {
+ for (const chunk of text.data.audio.chunks) {
+ try {
+ await FileSystem.deleteAsync(`${AUDIO_DIR}${chunk.filename}`);
+ } catch (e) {
+ console.log('Fehler beim Löschen:', e);
+ }
+ }
+ }
+
+ // Metadaten updaten
+ await supabase
+ .from('texts')
+ .update({
+ data: {
+ ...text.data,
+ audio: {
+ hasLocalCache: false,
+ chunks: [],
+ totalSize: 0
+ }
+ }
+ })
+ .eq('id', textId);
+ };
+
+ // Cache-Größe berechnen
+ const getCacheSize = async () => {
+ const files = await FileSystem.readDirectoryAsync(AUDIO_DIR);
+ let totalSize = 0;
+
+ for (const file of files) {
+ const info = await FileSystem.getInfoAsync(`${AUDIO_DIR}${file}`);
+ totalSize += info.size || 0;
+ }
+
+ return totalSize;
+ };
+
+ return {
+ generateAudioForText,
+ playAudioFromCache,
+ clearAudioCache,
+ getCacheSize
+ };
+};
+```
+
+## Beispiel-Screen für Audio-Management
+
+```javascript
+// screens/TextDetailScreen.js
+import React, { useState } from 'react';
+import { View, Text, Button, ActivityIndicator } from 'react-native';
+import { useAudioCache } from '../hooks/useAudioCache';
+
+export const TextDetailScreen = ({ route }) => {
+ const { text } = route.params;
+ const { generateAudioForText, playAudioFromCache, clearAudioCache } = useAudioCache();
+ const [generating, setGenerating] = useState(false);
+
+ const hasCache = text.data?.audio?.hasLocalCache;
+
+ const handleGenerateAudio = async () => {
+ setGenerating(true);
+ try {
+ await generateAudioForText(text.id, text.content, {
+ voice: text.data?.tts?.voice || 'de-DE',
+ speed: text.data?.tts?.speed || 1.0
+ });
+ // Text-Objekt neu laden
+ } catch (error) {
+ console.error('Fehler beim Generieren:', error);
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ const handlePlay = async () => {
+ try {
+ const position = text.data?.tts?.lastPosition || 0;
+ await playAudioFromCache(text.id, position);
+ } catch (error) {
+ // Fallback zu expo-speech
+ Speech.speak(text.content.slice(position), {
+ language: text.data?.tts?.voice || 'de-DE',
+ rate: text.data?.tts?.speed || 1.0
+ });
+ }
+ };
+
+ return (
+
+ {text.title}
+
+ {!hasCache && (
+
+ )}
+
+ {generating && }
+
+ {hasCache && (
+ <>
+
+ Audio gespeichert: {(text.data.audio.totalSize / 1024 / 1024).toFixed(2)} MB
+
+
+
+ );
+};
+```
+
+## Vorteile dieser Struktur
+
+✅ **Eine Tabelle** = Keine Joins, keine Komplexität
+✅ **JSONB** = Unendlich erweiterbar ohne Migrations
+✅ **Performance** = PostgreSQL's JSONB ist super schnell
+✅ **Einfach** = Jeder versteht es sofort
+✅ **Flexibel** = Neue Features sind nur ein JSON-Feld entfernt
+
+## Erweiterungsbeispiele
+
+```javascript
+// Später: Lesezeichen hinzufügen
+data.bookmarks = [
+ { position: 1234, note: "Wichtige Stelle", created: "2024-01-15" }
+];
+
+// Später: Sharing hinzufügen
+data.sharing = {
+ isPublic: false,
+ shareToken: "abc123",
+ sharedWith: ["email@example.com"]
+};
+
+// Später: AI-Features
+data.ai = {
+ summary: "KI-generierte Zusammenfassung",
+ keywords: ["Thema1", "Thema2"],
+ difficulty: "medium"
+};
+```
+
+## Das war's! 🎉
+
+Mit dieser einen Tabelle kannst du:
+- Texte speichern ✓
+- Vorlesen mit gespeicherter Position ✓
+- Tags/Kategorien verwalten ✓
+- Statistiken tracken ✓
+- Beliebig erweitern ✓
+
+Keine zweite Tabelle nötig. Kein Over-Engineering. Einfach machen.
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/ReadMe/ProjectOverview.md b/apps/reader/apps/mobile/ReadMe/ProjectOverview.md
new file mode 100644
index 000000000..dd58766f5
--- /dev/null
+++ b/apps/reader/apps/mobile/ReadMe/ProjectOverview.md
@@ -0,0 +1,217 @@
+# Reader - Projektübersicht
+
+## Was ist Reader?
+
+Reader ist eine moderne Text-to-Speech App, die Texte mit hochqualitativen KI-Stimmen vorliest und für die Offline-Nutzung speichert. Die App kombiniert die neuesten Google Chirp Stimmen mit einer eleganten Benutzeroberfläche und intelligenter Audio-Verwaltung.
+
+## Kernfunktionen
+
+### 📚 Text-Management
+
+- **Import**: Texte manuell eingeben oder aus Dateien importieren
+- **Organisation**: Einfache Tag-basierte Verwaltung
+- **Synchronisation**: Automatischer Sync zwischen Geräten via Supabase
+- **Lesefortschritt**: Merkt sich wo du aufgehört hast
+
+### 🎧 Premium Audio-Wiedergabe
+
+- **Google Chirp Stimmen**: Natürlich klingende KI-Stimmen in Studio-Qualität
+- **Offline-Verfügbarkeit**: Einmal generiert, immer verfügbar
+- **Anpassbar**: Geschwindigkeit und Tonhöhe individuell einstellbar
+- **Nahtlose Wiedergabe**: Intelligentes Chunk-System für unterbrechungsfreies Hören
+
+### 💾 Smart Caching
+
+- **Automatische Segmentierung**: Lange Texte werden intelligent aufgeteilt
+- **Progressives Laden**: Chunks werden bei Bedarf geladen
+- **Speicherverwaltung**: Übersicht über genutzten Speicherplatz
+- **Selective Sync**: Wähle welche Texte offline verfügbar sein sollen
+
+### 👤 Benutzerfreundlichkeit
+
+- **Ein-Klick-Generierung**: Audio für komplette Texte erstellen
+- **Hintergrund-Wiedergabe**: Weiterhören während andere Apps genutzt werden
+- **Sleep Timer**: Automatisches Stoppen nach eingestellter Zeit
+- **Lesezeichen**: Wichtige Stellen markieren
+
+## Technische Architektur
+
+### Frontend: React Native mit Expo
+
+- **Plattformen**: iOS und Android aus einer Codebasis
+- **UI Framework**: Native Komponenten für beste Performance
+- **Offline-First**: Funktioniert auch ohne Internetverbindung
+- **State Management**: React Context für einfache Datenverwaltung
+
+### Backend: Supabase
+
+- **Datenbank**: PostgreSQL mit einer minimalistischen Tabelle
+- **Authentifizierung**: Sichere Benutzerkonten out-of-the-box
+- **Realtime Sync**: Änderungen werden sofort synchronisiert
+- **Edge Functions**: Serverless Audio-Generierung
+
+### Audio-Pipeline: Google Cloud TTS
+
+- **Chirp Voices**: Neueste Generation von Google's Text-to-Speech
+- **Studio-Qualität**: Broadcast-taugliche Sprachausgabe
+- **Mehrsprachig**: Unterstützung für 40+ Sprachen
+- **Neural Synthesis**: KI-basierte Sprachgenerierung
+
+## Projektkonzept für Google Chirp Integration
+
+### Phase 1: Infrastruktur-Setup
+
+**Ziel**: Grundlegende Verbindungen zwischen allen Systemen herstellen
+
+**Google Cloud Konfiguration**:
+
+- Google Cloud Projekt erstellen und Text-to-Speech API aktivieren
+- Service Account für sichere API-Zugriffe einrichten
+- Zugriffsschlüssel generieren und sicher speichern
+- Kostenkontrolle durch Quotas und Budgets einrichten
+
+**Supabase Edge Functions Setup**:
+
+- Zwei Hauptfunktionen: Audio-Generierung und Batch-Processing
+- Sichere Speicherung der Google Cloud Credentials
+- CORS-Konfiguration für App-Zugriffe
+- Error Handling und Logging-Strategie
+
+### Phase 2: Audio-Generierungs-Pipeline
+
+**Ziel**: Robuste und skalierbare Audio-Erstellung
+
+**Text-Segmentierung**:
+
+- Intelligente Aufteilung an Satzgrenzen
+- Optimale Chunk-Größe für Balance zwischen Qualität und Performance
+- Metadaten für nahtlose Wiedergabe speichern
+
+**Batch-Processing**:
+
+- Parallele Verarbeitung mit Rate Limiting
+- Fortschrittsanzeige für Benutzer
+- Fehlerbehandlung für einzelne Chunks
+- Automatische Wiederholung bei Fehlern
+
+**Storage-Strategie**:
+
+- Supabase Storage für zentrale Audio-Dateien
+- Signierte URLs mit Ablaufzeit
+- Lokaler Cache auf Geräten
+- Intelligente Garbage Collection
+
+### Phase 3: App-Integration
+
+**Ziel**: Nahtlose Benutzererfahrung
+
+**Audio-Service Layer**:
+
+- Abstraktion der Komplexität
+- Queue-Management für Wiedergabe
+- Prefetching für unterbrechungsfreies Hören
+- Fallback-Mechanismen
+
+**UI/UX Konzepte**:
+
+- Ein-Tap Audio-Generierung
+- Visuelles Feedback während Processing
+- Download-Progress für Offline-Sync
+- Intuitive Playback-Controls
+
+### Phase 4: Optimierung & Skalierung
+
+**Ziel**: Production-ready System
+
+**Performance**:
+
+- CDN-Integration für schnelle Downloads
+- Chunk-Größen-Optimierung
+- Parallele Downloads
+- Background Processing
+
+**Kosten-Optimierung**:
+
+- Caching bereits generierter Audios
+- Deduplizierung gleicher Textpassagen
+- Nutzungsbasierte Limits
+- Premium-Tier für Heavy Users
+
+**Monitoring**:
+
+- Verwendungsstatistiken
+- Error Tracking
+- Performance Metriken
+- Kosten-Überwachung
+
+## Alleinstellungsmerkmale
+
+### 🎯 Was Reader besonders macht:
+
+1. **Höchste Audioqualität**: Google Chirp Stimmen klingen natürlicher als Standard TTS
+2. **True Offline**: Einmal generiert, für immer verfügbar - kein Streaming nötig
+3. **Minimalistisches Design**: Fokus auf das Wesentliche ohne überflüssige Features
+4. **Privacy-First**: Deine Texte bleiben deine Texte
+5. **Fair Pricing**: Einmalige Generierung statt ständige Streaming-Kosten
+
+## Monetarisierung
+
+### Freemium Modell:
+
+- **Free Tier**: 10.000 Zeichen/Monat
+- **Pro**: 500.000 Zeichen/Monat + Premium Stimmen
+- **Team**: Unbegrenzt + Collaboration Features
+
+### Kostenstruktur:
+
+- Google TTS: ~$16 per 1 Million Zeichen (Chirp Voices)
+- Supabase: $25/Monat für Pro Features
+- Storage: $0.021 per GB/Monat
+
+## Zeitplan
+
+**Woche 1-2**: Setup & Basis-Integration
+
+- Google Cloud und Supabase konfigurieren
+- Edge Functions entwickeln
+- Basis-App mit Authentifizierung
+
+**Woche 3-4**: Audio-Pipeline
+
+- Chunk-System implementieren
+- Storage-Integration
+- Playback-Funktionalität
+
+**Woche 5-6**: Polish & Launch
+
+- UI/UX Verfeinerung
+- Testing & Bugfixing
+- App Store Vorbereitung
+
+## Erfolgsmetriken
+
+- **Nutzer-Aktivierung**: 80% generieren ersten Audio innerhalb 5 Minuten
+- **Retention**: 40% Daily Active Users
+- **Audio-Qualität**: <2% Neu-Generierungen wegen Qualität
+- **Performance**: <3 Sekunden für Start der Wiedergabe
+- **Conversion**: 5% Free-to-Pro nach 30 Tagen
+
+## Risiken & Mitigationen
+
+**API-Kosten**:
+
+- Monitoring und Alerts
+- Caching-Strategien
+- User Limits
+
+**Technische Komplexität**:
+
+- Schrittweise Integration
+- Ausführliches Testing
+- Fallback-Optionen
+
+**Skalierung**:
+
+- Edge Function Limits beachten
+- CDN frühzeitig einplanen
+- Horizontale Skalierung vorbereiten
diff --git a/apps/reader/apps/mobile/app-env.d.ts b/apps/reader/apps/mobile/app-env.d.ts
new file mode 100644
index 000000000..88dc403ea
--- /dev/null
+++ b/apps/reader/apps/mobile/app-env.d.ts
@@ -0,0 +1,2 @@
+// @ts-ignore
+///
diff --git a/apps/reader/apps/mobile/app.json b/apps/reader/apps/mobile/app.json
new file mode 100644
index 000000000..2ebbe53aa
--- /dev/null
+++ b/apps/reader/apps/mobile/app.json
@@ -0,0 +1,47 @@
+{
+ "expo": {
+ "name": "reader",
+ "slug": "reader",
+ "version": "1.0.0",
+ "scheme": "reader",
+ "web": {
+ "bundler": "metro",
+ "output": "static",
+ "favicon": "./assets/favicon.png"
+ },
+ "plugins": [
+ "expo-router",
+ [
+ "expo-dev-launcher",
+ {
+ "launchMode": "most-recent"
+ }
+ ],
+ "expo-web-browser"
+ ],
+ "experiments": {
+ "typedRoutes": true,
+ "tsconfigPaths": true
+ },
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "light",
+ "splash": {
+ "image": "./assets/splash.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ },
+ "assetBundlePatterns": ["**/*"],
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "com.tilljs.reader"
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/adaptive-icon.png",
+ "backgroundColor": "#ffffff"
+ },
+ "package": "com.tilljs.reader"
+ }
+ }
+}
diff --git a/apps/reader/apps/mobile/app/(auth)/_layout.tsx b/apps/reader/apps/mobile/app/(auth)/_layout.tsx
new file mode 100644
index 000000000..d064b9575
--- /dev/null
+++ b/apps/reader/apps/mobile/app/(auth)/_layout.tsx
@@ -0,0 +1,15 @@
+import { Stack } from 'expo-router';
+
+export default function AuthLayout() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/reader/apps/mobile/app/(auth)/forgot-password.tsx b/apps/reader/apps/mobile/app/(auth)/forgot-password.tsx
new file mode 100644
index 000000000..ff31bbf3e
--- /dev/null
+++ b/apps/reader/apps/mobile/app/(auth)/forgot-password.tsx
@@ -0,0 +1,120 @@
+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(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 (
+
+
+ E-Mail gesendet!
+
+ Wir haben dir einen Link zum Zurücksetzen deines Passworts gesendet. Überprüfe deine
+ E-Mails und folge den Anweisungen.
+
+
+
+
+ Zurück zum Login
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Passwort zurücksetzen
+
+ Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ E-Mail
+
+
+
+
+ {loading ? (
+
+ ) : (
+
+ Reset-Link senden
+
+ )}
+
+
+
+ Erinnerst du dich wieder?
+
+
+ Anmelden
+
+
+
+
+
+
+ );
+}
diff --git a/apps/reader/apps/mobile/app/(auth)/login.tsx b/apps/reader/apps/mobile/app/(auth)/login.tsx
new file mode 100644
index 000000000..4d3e25ce2
--- /dev/null
+++ b/apps/reader/apps/mobile/app/(auth)/login.tsx
@@ -0,0 +1,125 @@
+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(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 (
+
+
+
+ Willkommen zurück
+ Melde dich an, um fortzufahren
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ E-Mail
+
+
+
+
+ Passwort
+
+
+
+
+ {loading ? (
+
+ ) : (
+ Anmelden
+ )}
+
+
+
+ Noch kein Konto?
+
+
+ Registrieren
+
+
+
+
+
+
+ Passwort vergessen?
+
+
+
+
+
+ );
+}
diff --git a/apps/reader/apps/mobile/app/(auth)/register.tsx b/apps/reader/apps/mobile/app/(auth)/register.tsx
new file mode 100644
index 000000000..517b77967
--- /dev/null
+++ b/apps/reader/apps/mobile/app/(auth)/register.tsx
@@ -0,0 +1,144 @@
+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(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 (
+
+
+
+ Konto erstellen
+ Registriere dich für Reader
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ E-Mail
+
+
+
+
+ Passwort
+
+
+
+
+ Passwort bestätigen
+
+
+
+
+ {loading ? (
+
+ ) : (
+ Registrieren
+ )}
+
+
+
+ Schon ein Konto?
+
+
+ Anmelden
+
+
+
+
+
+
+ );
+}
diff --git a/apps/reader/apps/mobile/app/(tabs)/_layout.tsx b/apps/reader/apps/mobile/app/(tabs)/_layout.tsx
new file mode 100644
index 000000000..464d87cf6
--- /dev/null
+++ b/apps/reader/apps/mobile/app/(tabs)/_layout.tsx
@@ -0,0 +1,34 @@
+import { Tabs } from 'expo-router';
+import { TabBarIcon } from '../../components/TabBarIcon';
+import { useTheme } from '~/hooks/useTheme';
+
+export default function TabLayout() {
+ const { colors } = useTheme();
+
+ return (
+
+ ,
+ }}
+ />
+ ,
+ }}
+ />
+
+ );
+}
diff --git a/apps/reader/apps/mobile/app/(tabs)/index.tsx b/apps/reader/apps/mobile/app/(tabs)/index.tsx
new file mode 100644
index 000000000..c920f1cd6
--- /dev/null
+++ b/apps/reader/apps/mobile/app/(tabs)/index.tsx
@@ -0,0 +1,338 @@
+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 }) => (
+
+ );
+
+ if (loading) {
+ return (
+ <>
+
+
+
+
+ Texte werden geladen...
+
+ >
+ );
+ }
+
+ if (error) {
+ return (
+ <>
+
+
+
+ {error}
+ refetch()} className={`rounded-lg ${colors.primary} px-4 py-2`}>
+ Erneut versuchen
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+ {texts.length === 0 ? (
+
+
+ Noch keine Texte vorhanden
+
+ router.push('/add-text')}
+ className={`rounded-lg ${colors.primary} px-6 py-3`}>
+ Ersten Text hinzufügen
+
+
+ ) : filteredTexts.length === 0 ? (
+
+
+ Keine Texte mit den gewählten Tags gefunden
+
+ router.push('/add-text')}
+ className={`rounded-lg ${colors.primary} px-6 py-3`}>
+ Neuen Text hinzufügen
+
+
+ ) : (
+ item.id}
+ contentContainerStyle={{ padding: 16, paddingBottom: 100 }}
+ showsVerticalScrollIndicator={false}
+ />
+ )}
+
+
+
+ router.push('/add-text')}
+ icon="+"
+ label="Neuer Text"
+ style={{ marginRight: 12 }}
+ />
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/reader/apps/mobile/app/(tabs)/two.tsx b/apps/reader/apps/mobile/app/(tabs)/two.tsx
new file mode 100644
index 000000000..740fd6243
--- /dev/null
+++ b/apps/reader/apps/mobile/app/(tabs)/two.tsx
@@ -0,0 +1,211 @@
+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 (
+ <>
+
+
+
+
+
+ {/* Statistics */}
+
+ Statistiken
+
+
+
+ Texte gesamt:
+ {totalTexts}
+
+
+
+ Tags:
+ {totalTags}
+
+
+
+ Texte mit Audio:
+ {textsWithAudio}
+
+
+
+ Audio-Speicher:
+
+ {(totalAudioSize / 1024 / 1024).toFixed(2)} MB
+
+
+
+
+
+ {/* Audio Settings */}
+
+ Audio-Einstellungen
+
+
+ Stimme
+ 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>
+ )
+ ).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,
+ }))
+ ),
+ }))}
+ />
+
+
+
+
+ Geschwindigkeit
+
+
+ {speeds.map((speed) => (
+ updateSettings({ speed: speed.value })}
+ className={`rounded-lg border p-3 ${
+ settings.speed === speed.value
+ ? `border-blue-500 ${colors.primaryLight}`
+ : colors.border
+ }`}>
+
+ {speed.label}
+
+
+ ))}
+
+
+
+
+ {/* App Settings */}
+
+ App-Einstellungen
+
+
+ Design
+
+ {themes.map((theme) => (
+ updateSettings({ theme: theme.value as 'light' | 'dark' })}
+ className={`rounded-lg border p-3 ${
+ settings.theme === theme.value
+ ? `border-blue-500 ${colors.primaryLight}`
+ : colors.border
+ }`}>
+
+ {theme.label}
+
+
+ ))}
+
+
+
+
+ {/* App Info */}
+
+ App Info
+
+
+
+ Version:
+ 1.0.0
+
+
+
+ Build:
+ 1
+
+
+
+
+ {/* User Info */}
+
+ Konto
+ {user?.email}
+
+ Abmelden
+
+
+
+
+ >
+ );
+}
diff --git a/apps/reader/apps/mobile/app/+html.tsx b/apps/reader/apps/mobile/app/+html.tsx
new file mode 100644
index 000000000..2fe284848
--- /dev/null
+++ b/apps/reader/apps/mobile/app/+html.tsx
@@ -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 (
+
+
+
+
+
+ {/*
+ 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:
+
+ */}
+
+ {/*
+ 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.
+ */}
+
+
+ {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
+
+ {/* Add any additional elements that you want globally available on web... */}
+
+ {children}
+
+ );
+}
+
+const responsiveBackground = `
+body {
+ background-color: #fff;
+}
+@media (prefers-color-scheme: dark) {
+ body {
+ background-color: #000;
+ }
+}`;
diff --git a/apps/reader/apps/mobile/app/+not-found.tsx b/apps/reader/apps/mobile/app/+not-found.tsx
new file mode 100644
index 000000000..9db9a6d42
--- /dev/null
+++ b/apps/reader/apps/mobile/app/+not-found.tsx
@@ -0,0 +1,24 @@
+import { Link, Stack } from 'expo-router';
+
+import { Text, View } from 'react-native';
+
+export default function NotFoundScreen() {
+ return (
+ <>
+
+
+ {"This screen doesn't exist."}
+
+ Go to home screen!
+
+
+ >
+ );
+}
+
+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]`,
+};
diff --git a/apps/reader/apps/mobile/app/_layout.tsx b/apps/reader/apps/mobile/app/_layout.tsx
new file mode 100644
index 000000000..6ff7f0987
--- /dev/null
+++ b/apps/reader/apps/mobile/app/_layout.tsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/apps/reader/apps/mobile/app/add-text.tsx b/apps/reader/apps/mobile/app/add-text.tsx
new file mode 100644
index 000000000..8f533ef7b
--- /dev/null
+++ b/apps/reader/apps/mobile/app/add-text.tsx
@@ -0,0 +1,269 @@
+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(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 (
+
+
+
+ {loading ? (
+
+ ) : (
+ Speichern
+ )}
+
+ }
+ />
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ Titel
+
+
+
+
+
+ Tags (durch Komma getrennt)
+
+
+
+
+
+ Stimme
+ {
+ 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>
+ )
+ ).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,
+ }))
+ ),
+ }))}
+ />
+
+
+
+
+ setInputMode('text')}
+ className={`mr-2 rounded-lg px-4 py-2 ${inputMode === 'text' ? colors.primary : colors.surface}`}>
+
+ Text
+
+
+ setInputMode('url')}
+ className={`rounded-lg px-4 py-2 ${inputMode === 'url' ? colors.primary : colors.surface}`}>
+
+ URL
+
+
+
+
+ {inputMode === 'text' ? (
+
+ ) : (
+
+
+
+ {extracting ? (
+
+ ) : (
+ Text extrahieren
+ )}
+
+ {content && (
+
+ )}
+
+ )}
+
+
+
+
+ 💡 Tipp: Du kannst später Audio für diesen Text generieren und offline anhören.
+
+
+
+
+ {loading ? (
+
+ ) : (
+ Speichern
+ )}
+
+
+
+ );
+}
diff --git a/apps/reader/apps/mobile/app/text/[id].tsx b/apps/reader/apps/mobile/app/text/[id].tsx
new file mode 100644
index 000000000..95c8733e0
--- /dev/null
+++ b/apps/reader/apps/mobile/app/text/[id].tsx
@@ -0,0 +1,214 @@
+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(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 (
+ <>
+
+
+
+
+
+ >
+ );
+ }
+
+ if (!text) {
+ return (
+ <>
+
+
+
+
+ Der angeforderte Text wurde nicht gefunden.
+
+ router.back()}
+ className={`rounded-lg ${colors.primary} px-4 py-2`}>
+ Zurück
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ }
+ />
+
+
+
+
+
+ {text.title}
+
+
+
+
+ Erstellt: {formatDate(text.created_at)}
+
+ {text.updated_at !== text.created_at && (
+
+ Bearbeitet: {formatDate(text.updated_at)}
+
+ )}
+
+
+ {text.data.tags && text.data.tags.length > 0 ? (
+
+ {text.data.tags.map((tag, index) => (
+
+
+ {tag}
+
+
+ ))}
+
+ ) : null}
+
+
+
+
+ {text.content}
+
+
+
+ {
+ // Refresh text data after audio generation
+ const updatedText = texts.find((t) => t.id === text.id);
+ if (updatedText) {
+ setText(updatedText);
+ }
+ }}
+ />
+
+ {text.data.stats ? (
+
+
+ Statistiken
+
+
+
+
+ Wiedergaben:
+ {text.data.stats?.playCount || 0}
+
+
+ {text.data.stats?.totalTime ? (
+
+ Gesamtzeit:
+
+ {Math.floor(text.data.stats.totalTime / 60)}m {Math.round(text.data.stats.totalTime % 60)}s
+
+
+ ) : null}
+
+
+ Status:
+ {text.data.stats?.completed ? 'Abgeschlossen' : 'In Progress'}
+
+
+
+ ) : null}
+
+ {text.data.audio?.hasLocalCache ? (
+
+
+ Audio Cache
+
+
+
+
+ Chunks:
+ {text.data.audio?.chunks?.length || 0}
+
+
+
+ Größe:
+ {((text.data.audio?.totalSize || 0) / 1024 / 1024).toFixed(2)} MB
+
+
+ {text.data.audio?.lastGenerated ? (
+
+ Generiert:
+ {formatDate(text.data.audio.lastGenerated)}
+
+ ) : null}
+
+
+ ) : null}
+
+
+ >
+ );
+}
diff --git a/apps/reader/apps/mobile/assets/adaptive-icon.png b/apps/reader/apps/mobile/assets/adaptive-icon.png
new file mode 100644
index 000000000..03d6f6b6c
Binary files /dev/null and b/apps/reader/apps/mobile/assets/adaptive-icon.png differ
diff --git a/apps/reader/apps/mobile/assets/favicon.png b/apps/reader/apps/mobile/assets/favicon.png
new file mode 100644
index 000000000..e75f697b1
Binary files /dev/null and b/apps/reader/apps/mobile/assets/favicon.png differ
diff --git a/apps/reader/apps/mobile/assets/icon.png b/apps/reader/apps/mobile/assets/icon.png
new file mode 100644
index 000000000..a0b1526fc
Binary files /dev/null and b/apps/reader/apps/mobile/assets/icon.png differ
diff --git a/apps/reader/apps/mobile/assets/splash.png b/apps/reader/apps/mobile/assets/splash.png
new file mode 100644
index 000000000..0e89705a9
Binary files /dev/null and b/apps/reader/apps/mobile/assets/splash.png differ
diff --git a/apps/reader/apps/mobile/babel.config.js b/apps/reader/apps/mobile/babel.config.js
new file mode 100644
index 000000000..adf8ddb32
--- /dev/null
+++ b/apps/reader/apps/mobile/babel.config.js
@@ -0,0 +1,10 @@
+module.exports = function (api) {
+ api.cache(true);
+ let plugins = [];
+
+ return {
+ presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
+
+ plugins,
+ };
+};
diff --git a/apps/reader/apps/mobile/cesconfig.jsonc b/apps/reader/apps/mobile/cesconfig.jsonc
new file mode 100644
index 000000000..4f20b5a1b
--- /dev/null
+++ b/apps/reader/apps/mobile/cesconfig.jsonc
@@ -0,0 +1,46 @@
+// This is an optional configuration file used primarily for debugging purposes when reporting issues.
+// It is safe to delete this file as it does not affect the functionality of your application.
+{
+ "cesVersion": "2.18.6",
+ "projectName": "reader",
+ "packages": [
+ {
+ "name": "expo-router",
+ "type": "navigation",
+ "options": {
+ "type": "tabs"
+ }
+ },
+ {
+ "name": "nativewind",
+ "type": "styling"
+ },
+ {
+ "name": "zustand",
+ "type": "state-management"
+ },
+ {
+ "name": "supabase",
+ "type": "authentication"
+ }
+ ],
+ "flags": {
+ "noGit": false,
+ "noInstall": false,
+ "overwrite": false,
+ "importAlias": true,
+ "packageManager": "npm",
+ "eas": true,
+ "publish": false
+ },
+ "packageManager": {
+ "type": "npm",
+ "version": "10.8.2"
+ },
+ "os": {
+ "type": "Darwin",
+ "platform": "darwin",
+ "arch": "arm64",
+ "kernelVersion": "24.1.0"
+ }
+}
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/components/ActionMenu.tsx b/apps/reader/apps/mobile/components/ActionMenu.tsx
new file mode 100644
index 000000000..93ad5f6bf
--- /dev/null
+++ b/apps/reader/apps/mobile/components/ActionMenu.tsx
@@ -0,0 +1,186 @@
+import React from 'react';
+import {
+ Platform,
+ ActionSheetIOS,
+ Modal,
+ View,
+ Text,
+ Pressable,
+ StyleSheet,
+ FlatList,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useTheme } from '~/hooks/useTheme';
+
+interface ActionMenuOption {
+ title: string;
+ systemIcon?: string;
+ icon?: keyof typeof Ionicons.glyphMap;
+ destructive?: boolean;
+ disabled?: boolean;
+}
+
+interface ActionMenuProps {
+ options: ActionMenuOption[];
+ onSelect: (index: number) => void;
+ children: React.ReactElement;
+ title?: string;
+ message?: string;
+}
+
+export function ActionMenu({ options, onSelect, children, title, message }: ActionMenuProps) {
+ const [visible, setVisible] = React.useState(false);
+ const { colors } = useTheme();
+
+ const iconMap: Record = {
+ 'doc.text': 'document-text-outline',
+ 'play.circle': 'play-circle-outline',
+ 'square.and.arrow.up': 'share-outline',
+ tag: 'pricetag-outline',
+ trash: 'trash-outline',
+ };
+
+ const showActionSheet = () => {
+ if (Platform.OS === 'ios') {
+ const optionTitles = options.map((opt) => opt.title);
+ const destructiveButtonIndex = options.findIndex((opt) => opt.destructive);
+ const disabledButtonIndices = options
+ .map((opt, idx) => (opt.disabled ? idx : -1))
+ .filter((idx) => idx !== -1);
+
+ ActionSheetIOS.showActionSheetWithOptions(
+ {
+ options: [...optionTitles, 'Abbrechen'],
+ cancelButtonIndex: optionTitles.length,
+ destructiveButtonIndex: destructiveButtonIndex >= 0 ? destructiveButtonIndex : undefined,
+ disabledButtonIndices,
+ title,
+ message,
+ },
+ (buttonIndex) => {
+ if (buttonIndex !== optionTitles.length) {
+ onSelect(buttonIndex);
+ }
+ }
+ );
+ } else {
+ setVisible(true);
+ }
+ };
+
+ const handleSelect = (index: number) => {
+ setVisible(false);
+ setTimeout(() => onSelect(index), 100);
+ };
+
+ const renderOption = ({ item, index }: { item: ActionMenuOption; index: number }) => {
+ const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
+ const isDisabled = item.disabled;
+ const isDestructive = item.destructive;
+
+ return (
+ !isDisabled && handleSelect(index)}
+ disabled={isDisabled}
+ className={`flex-row items-center px-4 py-4`}
+ style={({ pressed }) => ({
+ backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
+ opacity: isDisabled ? 0.5 : 1,
+ })}>
+ {iconName && (
+
+ )}
+
+ {item.title}
+
+
+ );
+ };
+
+ return (
+ <>
+ {React.cloneElement(children, {
+ onLongPress: showActionSheet,
+ delayLongPress: 500,
+ } as any)}
+
+ {Platform.OS !== 'ios' && (
+ setVisible(false)}>
+ setVisible(false)}>
+
+
+
+
+ {(title || message) && (
+
+ {title && (
+ {title}
+ )}
+ {message && (
+
+ {message}
+
+ )}
+
+ )}
+
+ index.toString()}
+ scrollEnabled={false}
+ ItemSeparatorComponent={() => }
+ />
+
+
+ setVisible(false)}
+ className="py-4"
+ style={({ pressed }) => ({
+ backgroundColor: pressed ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
+ })}>
+ Abbrechen
+
+
+
+
+
+
+ )}
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
+ },
+ container: {
+ flex: 1,
+ justifyContent: 'flex-end',
+ },
+ menu: {
+ maxHeight: '80%',
+ ...Platform.select({
+ ios: {
+ // @ts-ignore - React Native Web supports boxShadow
+ boxShadow: '0px -2px 8px rgba(0, 0, 0, 0.1)',
+ },
+ android: {
+ elevation: 16,
+ },
+ }),
+ },
+});
diff --git a/apps/reader/apps/mobile/components/AudioPlayer.tsx b/apps/reader/apps/mobile/components/AudioPlayer.tsx
new file mode 100644
index 000000000..150a0ffad
--- /dev/null
+++ b/apps/reader/apps/mobile/components/AudioPlayer.tsx
@@ -0,0 +1,469 @@
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import {
+ View,
+ Text,
+ Pressable,
+ ActivityIndicator,
+ Alert,
+ Animated,
+ ScrollView,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useAudio } from '~/hooks/useAudio';
+import { Text as TextType, AudioVersion } from '~/types/database';
+import { useStore } from '~/store/store';
+import { useTheme } from '~/hooks/useTheme';
+import { Dropdown } from '~/components/dropdown';
+import { Voice, ALL_VOICES, getVoiceById, GERMAN_VOICES, PROVIDER_LABELS, QUALITY_LABELS } from '~/constants/voices';
+import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
+
+interface AudioPlayerProps {
+ text: TextType;
+ onAudioGenerated?: () => void;
+}
+
+export const AudioPlayer: React.FC = ({ text, onAudioGenerated }) => {
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [showSpeedControl, setShowSpeedControl] = useState(false);
+ const [selectedVoice, setSelectedVoice] = useState('');
+ const [showVersions, setShowVersions] = useState(false);
+ const progressBarRef = useRef(null);
+ const pulseAnim = useRef(new Animated.Value(1)).current;
+
+ const { settings, updateSettings } = useStore();
+ const { colors } = useTheme();
+
+ // Use useMemo to prevent re-migration on every render
+ const migratedData = useMemo(() => migrateAudioData(text.data), [text.data]);
+ const audioVersions = migratedData.audioVersions || [];
+ const currentVersion = useMemo(() => getCurrentAudioVersion(migratedData), [migratedData]);
+
+ // Initialize selectedVersionId with current version
+ const [selectedVersionId, setSelectedVersionId] = useState(currentVersion?.id || '');
+
+ // Initialize selected voice
+ useEffect(() => {
+ setSelectedVoice(settings.voice);
+ }, [settings.voice]);
+
+ const {
+ audioState,
+ generationProgress,
+ generateAudio,
+ playAudio,
+ pauseAudio,
+ resumeAudio,
+ stopAudio,
+ seekTo,
+ seekForward,
+ seekBackward,
+ setPlaybackSpeed,
+ clearCache,
+ } = useAudio();
+
+ // Pulsating animation for loading state
+ useEffect(() => {
+ if (audioState.isLoading) {
+ Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulseAnim, {
+ toValue: 1.2,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ Animated.timing(pulseAnim, {
+ toValue: 1,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ ])
+ ).start();
+ } else {
+ pulseAnim.setValue(1);
+ }
+ }, [audioState.isLoading, pulseAnim]);
+
+ const handleGenerateAudio = async () => {
+ try {
+ setIsGenerating(true);
+
+ await generateAudio(text.id, text.content, selectedVoice, settings.speed, text);
+
+ onAudioGenerated?.();
+
+ Alert.alert(
+ 'Audio generiert!',
+ 'Das Audio wurde erfolgreich generiert und ist jetzt verfügbar.'
+ );
+ } catch (error) {
+ Alert.alert(
+ 'Fehler',
+ error instanceof Error ? error.message : 'Fehler beim Generieren des Audios'
+ );
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ const handleVoiceChange = (newVoice: string) => {
+ setSelectedVoice(newVoice);
+ // Update the global settings
+ updateSettings({ voice: newVoice });
+ };
+
+ const handlePlayPause = async () => {
+ if (!selectedVersion?.chunks) return;
+
+ try {
+ if (audioState.isPlaying) {
+ await pauseAudio();
+ } else if (audioState.sound) {
+ await resumeAudio();
+ } else {
+ // Play directly from Supabase Storage
+ await playAudio(text.id, selectedVersion.chunks, text.data.tts?.lastPosition || 0);
+ }
+ } catch (error) {
+ Alert.alert(
+ 'Wiedergabe-Fehler',
+ error instanceof Error ? error.message : 'Fehler beim Abspielen des Audios'
+ );
+ }
+ };
+
+ const handleStop = async () => {
+ await stopAudio();
+ };
+
+ const formatTime = (milliseconds: number): string => {
+ const totalSeconds = Math.floor(milliseconds / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
+ };
+
+ const formatSize = (bytes: number): string => {
+ const mb = bytes / (1024 * 1024);
+ return `${mb.toFixed(1)} MB`;
+ };
+
+ const speedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
+
+ const handleSpeedChange = async (speed: number) => {
+ await setPlaybackSpeed(speed);
+ setShowSpeedControl(false);
+ };
+
+ // Use duration from audio state if available, otherwise calculate from chunks
+ const totalDuration =
+ audioState.duration ||
+ (selectedVersion?.chunks
+ ? selectedVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000
+ : 0);
+
+ // Handle progress bar press
+ const handleProgressPress = async (event: any) => {
+ if (progressBarRef.current && totalDuration > 0) {
+ progressBarRef.current.measure(async (x, y, width, height, pageX, pageY) => {
+ const touchX = event.nativeEvent.pageX - pageX;
+ const progress = Math.max(0, Math.min(1, touchX / width));
+ const newPosition = progress * totalDuration;
+
+ // If audio hasn't been started yet, start it at the desired position
+ if (!audioState.sound) {
+ await playAudio(text.id, text.data.audio!.chunks, newPosition);
+ } else {
+ await seekTo(newPosition);
+ }
+ });
+ }
+ };
+
+ // Get the selected audio version
+ const selectedVersion = audioVersions.find((v) => v.id === selectedVersionId) || currentVersion;
+ const hasAudio = selectedVersion && selectedVersion.chunks.length > 0;
+
+ return (
+
+ {/* Voice selection and generate button - always visible */}
+
+ Sprachauswahl
+ {
+ const provider = voice.provider;
+ const quality = voice.quality;
+ if (!groups[provider]) {
+ groups[provider] = {};
+ }
+ if (!groups[provider][quality]) {
+ groups[provider][quality] = [];
+ }
+ groups[provider][quality].push(voice);
+ return groups;
+ },
+ {} as Record>
+ )
+ ).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,
+ }))
+ ),
+ }))}
+ />
+
+
+ {isGenerating ? (
+
+
+
+ {generationProgress?.currentChunk || 'Generiere Audio...'}
+
+
+ ) : (
+
+
+
+ {hasAudio ? 'Audio neu generieren' : 'Audio generieren'}
+
+
+ )}
+
+
+ {generationProgress && (
+
+
+
+
+
+ {generationProgress.chunksCompleted} / {generationProgress.totalChunks} Chunks
+
+
+ )}
+
+
+ {/* Audio versions - only shown when audio exists */}
+ {audioVersions.length > 0 && (
+
+ setShowVersions(!showVersions)}
+ className="flex-row items-center justify-between">
+
+ Audio-Versionen ({audioVersions.length})
+
+
+
+
+ {showVersions && (
+
+ {audioVersions.map((version) => {
+ const voice = getVoiceById(version.settings.voice);
+ const isActive = version.id === selectedVersionId;
+ const date = new Date(version.createdAt);
+
+ return (
+ setSelectedVersionId(version.id)}
+ className={`mb-2 rounded-lg p-3 ${
+ isActive ? 'bg-blue-600' : colors.surfaceSecondary
+ }`}>
+
+
+
+ {date.toLocaleDateString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+ {voice?.label || version.settings.voice} • {version.settings.speed}x
+
+
+
+ {isActive && Aktiv}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {/* Audio player - only shown when audio exists */}
+ {hasAudio && (
+
+ {/* closing tag moved to end */}
+ {/* Progress bar and time info - full width */}
+
+ {/* Progress Bar with touch gestures */}
+
+
+ 0
+ ? `${(audioState.currentPosition / totalDuration) * 100}%`
+ : '0%',
+ }}
+ />
+ {/* Scrubber indicator */}
+ {totalDuration > 0 && (
+
+
+
+ )}
+
+
+
+ {/* Time display */}
+
+
+ {formatTime(audioState.currentPosition)}
+
+ {formatTime(totalDuration)}
+
+
+
+ {/* Controls row */}
+
+ {/* Stop button */}
+
+
+
+
+ {/* Backward 15s button */}
+ seekBackward(15)}
+ disabled={audioState.isLoading || !audioState.sound}
+ className={`rounded-full ${colors.surfaceSecondary} mr-2 p-2`}>
+
+
+
+ 15
+
+
+
+
+ {/* Play/Pause button */}
+
+
+ {audioState.isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Forward 15s button */}
+ seekForward(15)}
+ disabled={audioState.isLoading || !audioState.sound}
+ className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}>
+
+
+
+ 15
+
+
+
+
+ {/* Speed control button */}
+ setShowSpeedControl(!showSpeedControl)}
+ className={`rounded-full ${colors.surfaceSecondary} px-3 py-1.5`}>
+
+ {audioState.playbackRate}x
+
+
+
+
+ {/* Speed options dropdown */}
+ {showSpeedControl && (
+
+
+ {speedOptions.map((speed) => (
+ handleSpeedChange(speed)}
+ className={`mx-1 rounded px-3 py-1 ${
+ audioState.playbackRate === speed ? colors.primary : ''
+ }`}>
+
+ {speed}x
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/apps/reader/apps/mobile/components/Button.tsx b/apps/reader/apps/mobile/components/Button.tsx
new file mode 100644
index 000000000..0d1519f27
--- /dev/null
+++ b/apps/reader/apps/mobile/components/Button.tsx
@@ -0,0 +1,207 @@
+import React from 'react';
+import { Pressable, PressableProps, ActivityIndicator, View } from 'react-native';
+import { Icon, IconName } from './Icon';
+import { Text } from './Text';
+
+export type ButtonVariant =
+ | 'primary'
+ | 'secondary'
+ | 'outline'
+ | 'ghost'
+ | 'link'
+ | 'destructive'
+ | 'success'
+ | 'warning';
+
+export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+
+interface ButtonProps extends Omit {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ icon?: IconName;
+ iconPosition?: 'left' | 'right';
+ loading?: boolean;
+ disabled?: boolean;
+ fullWidth?: boolean;
+ className?: string;
+ children?: React.ReactNode;
+}
+
+export const Button: React.FC = ({
+ variant = 'primary',
+ size = 'md',
+ icon,
+ iconPosition = 'left',
+ loading = false,
+ disabled = false,
+ fullWidth = false,
+ className,
+ children,
+ ...props
+}) => {
+ const isDisabled = disabled || loading;
+
+ // Get variant styles
+ const getVariantClasses = () => {
+ switch (variant) {
+ case 'primary':
+ return 'bg-blue-600 active:bg-blue-700';
+ case 'secondary':
+ return 'bg-gray-600 active:bg-gray-700';
+ case 'outline':
+ return 'border border-gray-300 bg-white active:bg-gray-50';
+ case 'ghost':
+ return 'bg-transparent active:bg-gray-100';
+ case 'link':
+ return 'bg-transparent';
+ case 'destructive':
+ return 'bg-red-600 active:bg-red-700';
+ case 'success':
+ return 'bg-green-600 active:bg-green-700';
+ case 'warning':
+ return 'bg-yellow-600 active:bg-yellow-700';
+ default:
+ return 'bg-blue-600 active:bg-blue-700';
+ }
+ };
+
+ // Get size styles
+ const getSizeClasses = () => {
+ switch (size) {
+ case 'xs':
+ return 'px-2 py-1';
+ case 'sm':
+ return 'px-3 py-2';
+ case 'md':
+ return 'px-4 py-3';
+ case 'lg':
+ return 'px-6 py-4';
+ case 'xl':
+ return 'px-8 py-5';
+ default:
+ return 'px-4 py-3';
+ }
+ };
+
+ // Get text color
+ const getTextColor = () => {
+ if (isDisabled) return 'muted';
+ switch (variant) {
+ case 'primary':
+ case 'secondary':
+ case 'destructive':
+ case 'success':
+ case 'warning':
+ return 'white';
+ case 'outline':
+ case 'ghost':
+ return 'gray';
+ case 'link':
+ return 'primary';
+ default:
+ return 'white';
+ }
+ };
+
+ // Get icon color
+ const getIconColor = () => {
+ if (isDisabled) return '#9CA3AF';
+ switch (variant) {
+ case 'primary':
+ case 'secondary':
+ case 'destructive':
+ case 'success':
+ case 'warning':
+ return '#FFFFFF';
+ case 'outline':
+ case 'ghost':
+ return '#6B7280';
+ case 'link':
+ return '#2563EB';
+ default:
+ return '#FFFFFF';
+ }
+ };
+
+ // Get icon size
+ const getIconSize = () => {
+ switch (size) {
+ case 'xs':
+ return 14;
+ case 'sm':
+ return 16;
+ case 'md':
+ return 18;
+ case 'lg':
+ return 20;
+ case 'xl':
+ return 24;
+ default:
+ return 18;
+ }
+ };
+
+ // Get text variant
+ const getTextVariant = () => {
+ switch (size) {
+ case 'xs':
+ case 'sm':
+ return 'buttonSmall';
+ default:
+ return 'button';
+ }
+ };
+
+ const renderContent = () => {
+ if (loading) {
+ return ;
+ }
+
+ const iconElement = icon ? (
+
+ ) : null;
+
+ const textElement = children ? (
+
+ {children}
+
+ ) : null;
+
+ if (!icon && !children) {
+ return null;
+ }
+
+ if (icon && !children) {
+ return iconElement;
+ }
+
+ if (!icon && children) {
+ return textElement;
+ }
+
+ return (
+
+ {iconPosition === 'left' && iconElement}
+ {textElement}
+ {iconPosition === 'right' && iconElement}
+
+ );
+ };
+
+ const buttonClasses = [
+ 'rounded-lg items-center justify-center',
+ getSizeClasses(),
+ getVariantClasses(),
+ fullWidth ? 'w-full' : '',
+ isDisabled ? 'opacity-50' : '',
+ className,
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return (
+
+ {renderContent()}
+
+ );
+};
diff --git a/apps/reader/apps/mobile/components/ContextMenu.tsx b/apps/reader/apps/mobile/components/ContextMenu.tsx
new file mode 100644
index 000000000..266e8c3a5
--- /dev/null
+++ b/apps/reader/apps/mobile/components/ContextMenu.tsx
@@ -0,0 +1,156 @@
+import React, { useState, useRef } from 'react';
+import {
+ Modal,
+ View,
+ Text,
+ Pressable,
+ Dimensions,
+ Platform,
+ StyleSheet,
+ FlatList,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useTheme } from '~/hooks/useTheme';
+
+interface ContextMenuAction {
+ title: string;
+ systemIcon?: string;
+ icon?: keyof typeof Ionicons.glyphMap;
+ destructive?: boolean;
+ disabled?: boolean;
+}
+
+interface ContextMenuProps {
+ actions: ContextMenuAction[];
+ onPress: (index: number) => void;
+ children: React.ReactElement;
+}
+
+export function ContextMenu({ actions, onPress, children }: ContextMenuProps) {
+ const [visible, setVisible] = useState(false);
+ const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
+ const childRef = useRef(null);
+ const { colors } = useTheme();
+
+ const handleLongPress = () => {
+ childRef.current?.measure((x, y, width, height, pageX, pageY) => {
+ const screenHeight = Dimensions.get('window').height;
+ const menuHeight = actions.length * 50 + 20; // Approximate menu height
+
+ // Position menu above or below the pressed item based on available space
+ const posY = pageY + height + menuHeight > screenHeight ? pageY - menuHeight : pageY + height;
+
+ setMenuPosition({ x: pageX, y: posY });
+ setVisible(true);
+ });
+ };
+
+ const handleActionPress = (index: number) => {
+ setVisible(false);
+ // Small delay to allow modal to close before action
+ setTimeout(() => onPress(index), 100);
+ };
+
+ const iconMap: Record = {
+ 'doc.text': 'document-text-outline',
+ 'play.circle': 'play-circle-outline',
+ 'square.and.arrow.up': 'share-outline',
+ tag: 'pricetag-outline',
+ trash: 'trash-outline',
+ };
+
+ const renderAction = ({ item, index }: { item: ContextMenuAction; index: number }) => {
+ const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
+ const isDisabled = item.disabled;
+ const isDestructive = item.destructive;
+
+ return (
+ !isDisabled && handleActionPress(index)}
+ disabled={isDisabled}
+ className={`flex-row items-center px-4 py-3 ${
+ index < actions.length - 1 ? `border-b ${colors.border}` : ''
+ }`}
+ style={({ pressed }) => ({
+ backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
+ opacity: isDisabled ? 0.5 : 1,
+ })}>
+ {iconName && (
+
+ )}
+
+ {item.title}
+
+
+ );
+ };
+
+ return (
+ <>
+
+ {React.cloneElement(children, {
+ onLongPress: handleLongPress,
+ delayLongPress: 500,
+ } as any)}
+
+
+ setVisible(false)}>
+ setVisible(false)}>
+
+
+
+ index.toString()}
+ scrollEnabled={false}
+ />
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ menu: {
+ position: 'absolute',
+ borderRadius: 12,
+ overflow: 'hidden',
+ ...Platform.select({
+ ios: {
+ // @ts-ignore - React Native Web supports boxShadow
+ boxShadow: '0px 2px 10px rgba(0, 0, 0, 0.25)',
+ },
+ android: {
+ elevation: 8,
+ },
+ }),
+ },
+});
diff --git a/apps/reader/apps/mobile/components/EditScreenInfo.tsx b/apps/reader/apps/mobile/components/EditScreenInfo.tsx
new file mode 100644
index 000000000..6fd8e54ed
--- /dev/null
+++ b/apps/reader/apps/mobile/components/EditScreenInfo.tsx
@@ -0,0 +1,29 @@
+import { Text, View } from 'react-native';
+
+export const EditScreenInfo = ({ path }: { path: string }) => {
+ const title = 'Open up the code for this screen:';
+ const description =
+ 'Change any of the text, save the file, and your app will automatically update.';
+
+ return (
+
+
+ {title}
+
+ {path}
+
+ {description}
+
+
+ );
+};
+
+const styles = {
+ codeHighlightContainer: `rounded-md px-1`,
+ getStartedContainer: `items-center mx-12`,
+ getStartedText: `text-lg leading-6 text-center`,
+ helpContainer: `items-center mx-5 mt-4`,
+ helpLink: `py-4`,
+ helpLinkText: `text-center`,
+ homeScreenFilename: `my-2`,
+};
diff --git a/apps/reader/apps/mobile/components/FloatingActionButton.tsx b/apps/reader/apps/mobile/components/FloatingActionButton.tsx
new file mode 100644
index 000000000..57f93f8ae
--- /dev/null
+++ b/apps/reader/apps/mobile/components/FloatingActionButton.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { Pressable, Text, ActivityIndicator, ViewStyle } from 'react-native';
+import { useTheme } from '~/hooks/useTheme';
+
+interface FloatingActionButtonProps {
+ onPress: () => void;
+ icon: string;
+ label: string;
+ disabled?: boolean;
+ loading?: boolean;
+ style?: ViewStyle;
+}
+
+export function FloatingActionButton({
+ onPress,
+ icon,
+ label,
+ disabled = false,
+ loading = false,
+ style,
+}: FloatingActionButtonProps) {
+ const { colors } = useTheme();
+
+ return (
+
+ {loading ? (
+
+ ) : (
+ <>
+ {icon}
+ {label}
+ >
+ )}
+
+ );
+}
diff --git a/apps/reader/apps/mobile/components/Header.tsx b/apps/reader/apps/mobile/components/Header.tsx
new file mode 100644
index 000000000..f616613e2
--- /dev/null
+++ b/apps/reader/apps/mobile/components/Header.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { View, Pressable, Platform, StatusBar } from 'react-native';
+import { router } from 'expo-router';
+import { Icon } from './Icon';
+import { Text } from './Text';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useTheme } from '~/hooks/useTheme';
+
+interface HeaderProps {
+ title?: string;
+ showBackButton?: boolean;
+ rightComponent?: React.ReactNode;
+ onBackPress?: () => void;
+ backgroundColor?: string;
+ textColor?: string;
+}
+
+export const Header: React.FC = ({
+ title,
+ showBackButton = true,
+ rightComponent,
+ onBackPress,
+ backgroundColor,
+ textColor,
+}) => {
+ const insets = useSafeAreaInsets();
+ const { isDark, colors } = useTheme();
+
+ const handleBackPress = () => {
+ if (onBackPress) {
+ onBackPress();
+ } else {
+ router.back();
+ }
+ };
+
+ // Use theme colors if not explicitly provided
+ const headerBackgroundColor = backgroundColor || (isDark ? colors.tabBarBackground : '#ffffff');
+ const headerTextColor = textColor || (isDark ? '#ffffff' : '#000000');
+ const borderColor = isDark ? colors.tabBarBorder : '#e5e7eb';
+
+ return (
+
+
+
+
+ {/* Left side - Back button */}
+
+ {showBackButton && (
+
+
+
+ )}
+
+
+ {/* Center - Title */}
+
+ {title && (
+
+ {title}
+
+ )}
+
+
+ {/* Right side - Custom component */}
+ {rightComponent}
+
+
+ );
+};
diff --git a/apps/reader/apps/mobile/components/Icon.tsx b/apps/reader/apps/mobile/components/Icon.tsx
new file mode 100644
index 000000000..2585858de
--- /dev/null
+++ b/apps/reader/apps/mobile/components/Icon.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import { View } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+
+export type IconName =
+ | 'add'
+ | 'delete'
+ | 'edit'
+ | 'save'
+ | 'close'
+ | 'back'
+ | 'play'
+ | 'pause'
+ | 'stop'
+ | 'refresh'
+ | 'settings'
+ | 'logout'
+ | 'eye'
+ | 'eye-off'
+ | 'heart'
+ | 'heart-outline'
+ | 'tag'
+ | 'filter'
+ | 'search'
+ | 'download'
+ | 'share'
+ | 'volume-high'
+ | 'volume-low'
+ | 'volume-mute'
+ | 'fast-forward'
+ | 'rewind'
+ | 'skip-forward'
+ | 'skip-backward'
+ | 'checkmark'
+ | 'close-circle'
+ | 'alert-circle'
+ | 'information-circle'
+ | 'chevron-down'
+ | 'chevron-up'
+ | 'chevron-left'
+ | 'chevron-right'
+ | 'arrow-back'
+ | 'arrow-forward'
+ | 'home'
+ | 'library'
+ | 'person'
+ | 'menu'
+ | 'more-horizontal'
+ | 'more-vertical'
+ | 'replay-15'
+ | 'forward-15'
+ | 'play-circle'
+ | 'pause-circle'
+ | 'mic-circle';
+
+interface IconProps {
+ name: IconName;
+ size?: number;
+ color?: string;
+ className?: string;
+}
+
+const iconMapping: Record = {
+ add: 'add',
+ delete: 'trash',
+ edit: 'pencil',
+ save: 'save',
+ close: 'close',
+ back: 'arrow-back',
+ play: 'play',
+ pause: 'pause',
+ stop: 'stop',
+ refresh: 'refresh',
+ settings: 'settings',
+ logout: 'log-out',
+ eye: 'eye',
+ 'eye-off': 'eye-off',
+ heart: 'heart',
+ 'heart-outline': 'heart-outline',
+ tag: 'pricetag',
+ filter: 'filter',
+ search: 'search',
+ download: 'download',
+ share: 'share',
+ 'volume-high': 'volume-high',
+ 'volume-low': 'volume-low',
+ 'volume-mute': 'volume-mute',
+ 'fast-forward': 'play-forward',
+ rewind: 'play-back',
+ 'skip-forward': 'play-skip-forward',
+ 'skip-backward': 'play-skip-back',
+ checkmark: 'checkmark',
+ 'close-circle': 'close-circle',
+ 'alert-circle': 'alert-circle',
+ 'information-circle': 'information-circle',
+ 'chevron-down': 'chevron-down',
+ 'chevron-up': 'chevron-up',
+ 'chevron-left': 'chevron-back',
+ 'chevron-right': 'chevron-forward',
+ 'arrow-back': 'arrow-back',
+ 'arrow-forward': 'arrow-forward',
+ home: 'home',
+ library: 'library',
+ person: 'person',
+ menu: 'menu',
+ 'more-horizontal': 'ellipsis-horizontal',
+ 'more-vertical': 'ellipsis-vertical',
+ 'replay-15': 'refresh-circle',
+ 'forward-15': 'add-circle',
+ 'play-circle': 'play-circle',
+ 'pause-circle': 'pause-circle',
+ 'mic-circle': 'mic-circle',
+};
+
+export const Icon: React.FC = ({ name, size = 24, color = '#000000', className }) => {
+ const ionIconName = iconMapping[name];
+
+ if (!ionIconName) {
+ console.warn(`Icon "${name}" not found in iconMapping`);
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/apps/reader/apps/mobile/components/MinimalAudioPlayer.tsx b/apps/reader/apps/mobile/components/MinimalAudioPlayer.tsx
new file mode 100644
index 000000000..e973dadf4
--- /dev/null
+++ b/apps/reader/apps/mobile/components/MinimalAudioPlayer.tsx
@@ -0,0 +1,90 @@
+import React, { useState, useEffect } from 'react';
+import { View, Pressable, ActivityIndicator } from 'react-native';
+import { Icon } from '~/components/Icon';
+import { useAudio } from '~/hooks/useAudio';
+import { Text as TextType } from '~/types/database';
+import { useStore } from '~/store/store';
+import { useTheme } from '~/hooks/useTheme';
+import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
+
+interface MinimalAudioPlayerProps {
+ text: TextType;
+}
+
+export const MinimalAudioPlayer: React.FC = ({ text }) => {
+ const [isGenerating, setIsGenerating] = useState(false);
+ const { currentTextId } = useStore();
+ const { colors } = useTheme();
+
+ const { audioState, generateAudio, playAudio, pauseAudio, resumeAudio, stopAudio } = useAudio();
+
+ // Check if this text is currently playing
+ const isCurrentText = currentTextId === text.id;
+ const isPlaying = isCurrentText && audioState.isPlaying;
+ const isLoading = isCurrentText && audioState.isLoading;
+
+ // Get audio version
+ const migratedData = migrateAudioData(text.data);
+ const currentVersion = getCurrentAudioVersion(migratedData);
+ const hasAudio = currentVersion && currentVersion.chunks.length > 0;
+
+ // Stop audio when component unmounts or text changes
+ useEffect(() => {
+ return () => {
+ if (isCurrentText) {
+ stopAudio();
+ }
+ };
+ }, [isCurrentText, stopAudio]);
+
+ const handlePlayPause = async () => {
+ if (!hasAudio) {
+ // Generate audio if not available
+ try {
+ setIsGenerating(true);
+ const { settings } = useStore.getState();
+ await generateAudio(text.id, text.content, settings.voice, settings.speed, text);
+ } catch (error) {
+ console.error('Error generating audio:', error);
+ } finally {
+ setIsGenerating(false);
+ }
+ return;
+ }
+
+ try {
+ if (isPlaying) {
+ await pauseAudio();
+ } else if (isCurrentText && audioState.sound) {
+ await resumeAudio();
+ } else {
+ // Stop any other playing audio and start this one
+ if (currentTextId && currentTextId !== text.id) {
+ await stopAudio();
+ }
+ await playAudio(text.id, currentVersion.chunks, 0);
+ }
+ } catch (error) {
+ console.error('Error playing audio:', error);
+ }
+ };
+
+ return (
+
+ {isLoading || isGenerating ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/apps/reader/apps/mobile/components/ScreenContent.tsx b/apps/reader/apps/mobile/components/ScreenContent.tsx
new file mode 100644
index 000000000..99682ae89
--- /dev/null
+++ b/apps/reader/apps/mobile/components/ScreenContent.tsx
@@ -0,0 +1,25 @@
+import { Text, View } from 'react-native';
+
+import { EditScreenInfo } from './EditScreenInfo';
+
+type ScreenContentProps = {
+ title: string;
+ path: string;
+ children?: React.ReactNode;
+};
+
+export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
+ return (
+
+ {title}
+
+
+ {children}
+
+ );
+};
+const styles = {
+ container: `items-center flex-1 justify-center`,
+ separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
+ title: `text-xl font-bold`,
+};
diff --git a/apps/reader/apps/mobile/components/TabBarIcon.tsx b/apps/reader/apps/mobile/components/TabBarIcon.tsx
new file mode 100644
index 000000000..e75c9d3fe
--- /dev/null
+++ b/apps/reader/apps/mobile/components/TabBarIcon.tsx
@@ -0,0 +1,15 @@
+import FontAwesome from '@expo/vector-icons/FontAwesome';
+import { StyleSheet } from 'react-native';
+
+export const TabBarIcon = (props: {
+ name: React.ComponentProps['name'];
+ color: string;
+}) => {
+ return ;
+};
+
+export const styles = StyleSheet.create({
+ tabBarIcon: {
+ marginBottom: -3,
+ },
+});
diff --git a/apps/reader/apps/mobile/components/TagFilter.tsx b/apps/reader/apps/mobile/components/TagFilter.tsx
new file mode 100644
index 000000000..17d0165a1
--- /dev/null
+++ b/apps/reader/apps/mobile/components/TagFilter.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { View, Text, ScrollView, Pressable } from 'react-native';
+import { useTexts } from '~/hooks/useTexts';
+import { useStore } from '~/store/store';
+import { useTheme } from '~/hooks/useTheme';
+
+export const TagFilter: React.FC = () => {
+ const { getAllTags } = useTexts();
+ const { selectedTags, toggleTag, clearTags } = useStore();
+ const { colors } = useTheme();
+
+ const allTags = getAllTags();
+
+ if (allTags.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ Tags filtern:
+ {selectedTags.length > 0 && (
+
+ Alle entfernen
+
+ )}
+
+
+
+ {allTags.map((tag) => {
+ const isSelected = selectedTags.includes(tag);
+ return (
+ toggleTag(tag)}
+ className={`mr-2 rounded-full border px-3 py-1 ${
+ isSelected
+ ? `border-blue-500 ${colors.primaryLight}`
+ : `${colors.borderSecondary} ${colors.surfaceSecondary}`
+ }`}>
+
+ {tag}
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/apps/reader/apps/mobile/components/Text.tsx b/apps/reader/apps/mobile/components/Text.tsx
new file mode 100644
index 000000000..eb27ced6c
--- /dev/null
+++ b/apps/reader/apps/mobile/components/Text.tsx
@@ -0,0 +1,158 @@
+import React from 'react';
+import { Text as RNText, TextProps as RNTextProps } from 'react-native';
+import { useTheme } from '~/hooks/useTheme';
+
+export type TextVariant =
+ | 'h1'
+ | 'h2'
+ | 'h3'
+ | 'h4'
+ | 'h5'
+ | 'h6'
+ | 'body'
+ | 'bodyLarge'
+ | 'bodySmall'
+ | 'caption'
+ | 'label'
+ | 'labelLarge'
+ | 'labelSmall'
+ | 'button'
+ | 'buttonSmall'
+ | 'overline'
+ | 'subtitle1'
+ | 'subtitle2';
+
+export type TextColor =
+ | 'primary'
+ | 'secondary'
+ | 'tertiary'
+ | 'accent'
+ | 'error'
+ | 'warning'
+ | 'success'
+ | 'info'
+ | 'white'
+ | 'black'
+ | 'gray'
+ | 'muted'
+ | 'red'
+ | 'blue'
+ | 'green'
+ | 'yellow'
+ | 'purple'
+ | 'pink'
+ | 'indigo'
+ | 'cyan'
+ | 'orange'
+ | 'inherit';
+
+interface TextComponentProps extends RNTextProps {
+ variant?: TextVariant;
+ color?: TextColor;
+ weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
+ align?: 'left' | 'center' | 'right' | 'justify';
+ className?: string;
+ children: React.ReactNode;
+}
+
+const variantStyles: Record = {
+ h1: 'text-4xl font-bold',
+ h2: 'text-3xl font-bold',
+ h3: 'text-2xl font-bold',
+ h4: 'text-xl font-bold',
+ h5: 'text-lg font-bold',
+ h6: 'text-base font-bold',
+ body: 'text-base',
+ bodyLarge: 'text-lg',
+ bodySmall: 'text-sm',
+ caption: 'text-xs',
+ label: 'text-sm font-medium',
+ labelLarge: 'text-base font-medium',
+ labelSmall: 'text-xs font-medium',
+ button: 'text-base font-semibold',
+ buttonSmall: 'text-sm font-semibold',
+ overline: 'text-xs font-medium uppercase tracking-wide',
+ subtitle1: 'text-base font-medium',
+ subtitle2: 'text-sm font-medium',
+};
+
+const colorStyles: Record = {
+ primary: 'text-blue-600',
+ secondary: 'text-gray-600',
+ accent: 'text-purple-600',
+ error: 'text-red-600',
+ warning: 'text-yellow-600',
+ success: 'text-green-600',
+ info: 'text-blue-500',
+ white: 'text-white',
+ black: 'text-black',
+ gray: 'text-gray-500',
+ muted: 'text-gray-400',
+ red: 'text-red-600',
+ blue: 'text-blue-600',
+ green: 'text-green-600',
+ yellow: 'text-yellow-600',
+ purple: 'text-purple-600',
+ pink: 'text-pink-600',
+ indigo: 'text-indigo-600',
+ cyan: 'text-cyan-600',
+ orange: 'text-orange-600',
+};
+
+const weightStyles: Record = {
+ light: 'font-light',
+ normal: 'font-normal',
+ medium: 'font-medium',
+ semibold: 'font-semibold',
+ bold: 'font-bold',
+};
+
+const alignStyles: Record = {
+ left: 'text-left',
+ center: 'text-center',
+ right: 'text-right',
+ justify: 'text-justify',
+};
+
+export const Text: React.FC = ({
+ variant = 'body',
+ color = 'inherit',
+ weight,
+ align,
+ className,
+ children,
+ ...props
+}) => {
+ const { colors } = useTheme();
+
+ // Map semantic colors to theme colors
+ const getThemeColor = (textColor: TextColor): string => {
+ switch (textColor) {
+ case 'inherit':
+ case 'primary':
+ return colors.text;
+ case 'secondary':
+ return colors.textSecondary;
+ case 'tertiary':
+ case 'muted':
+ return colors.textTertiary;
+ default:
+ return colorStyles[textColor] || colors.text;
+ }
+ };
+
+ const variantClass = variantStyles[variant];
+ const colorClass = getThemeColor(color);
+ const weightClass = weight ? weightStyles[weight] : '';
+ const alignClass = align ? alignStyles[align] : '';
+
+ const combinedClassName = [variantClass, colorClass, weightClass, alignClass, className]
+ .filter(Boolean)
+ .join(' ');
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/reader/apps/mobile/components/TextListItem.tsx b/apps/reader/apps/mobile/components/TextListItem.tsx
new file mode 100644
index 000000000..0f050dcb9
--- /dev/null
+++ b/apps/reader/apps/mobile/components/TextListItem.tsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import { View, Text, Pressable } from 'react-native';
+import { router } from 'expo-router';
+import { ActionMenu } from '~/components/ActionMenu';
+import { MinimalAudioPlayer } from '~/components/MinimalAudioPlayer';
+import { Text as TextType } from '~/types/database';
+import { useTheme } from '~/hooks/useTheme';
+
+interface TextListItemProps {
+ item: TextType;
+ onShare: (text: TextType) => void;
+ onDelete: (textId: string, title: string) => void;
+ formatDate: (dateString: string) => string;
+ getAudioDuration: (item: TextType) => string | null;
+}
+
+export const TextListItem: React.FC = ({
+ item,
+ onShare,
+ onDelete,
+ formatDate,
+ getAudioDuration,
+}) => {
+ const { colors } = useTheme();
+
+ const handleMenuSelect = (index: number) => {
+ switch (index) {
+ case 0: // Öffnen
+ router.push(`/text/${item.id}`);
+ break;
+ case 1: // Teilen
+ onShare(item);
+ break;
+ case 2: // Tags bearbeiten
+ router.push(`/text/${item.id}`);
+ break;
+ case 3: // Löschen
+ onDelete(item.id, item.title);
+ break;
+ }
+ };
+
+ return (
+
+ router.push(`/text/${item.id}`)}
+ className={`mb-3 rounded-lg border ${colors.border} ${colors.surface} p-4 shadow-sm`}>
+ {/* Header with title and date/duration */}
+
+
+ {item.title}
+
+
+ {formatDate(item.updated_at)}
+ {getAudioDuration(item) && (
+ <>
+ •
+ {getAudioDuration(item)}
+ >
+ )}
+
+
+
+ {/* Content preview */}
+
+ {item.content}
+
+
+ {/* Footer with tags and audio player */}
+
+
+ {item.data.tags?.map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/reader/apps/mobile/components/dropdown.tsx b/apps/reader/apps/mobile/components/dropdown.tsx
new file mode 100644
index 000000000..33e2c8576
--- /dev/null
+++ b/apps/reader/apps/mobile/components/dropdown.tsx
@@ -0,0 +1,135 @@
+import React, { useState } from 'react';
+import { View, Text, TouchableOpacity, Modal, ScrollView, Pressable } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useTheme } from '~/hooks/useTheme';
+
+interface DropdownOption {
+ label: string;
+ value: string;
+}
+
+interface DropdownGroup {
+ title: string;
+ options: DropdownOption[];
+}
+
+interface DropdownProps {
+ options: DropdownOption[];
+ groups?: DropdownGroup[];
+ value: string;
+ onValueChange: (value: string) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ title?: string;
+}
+
+export function Dropdown({
+ options,
+ groups,
+ value,
+ onValueChange,
+ placeholder = 'Select an option',
+ disabled = false,
+ title = 'Select Option',
+}: DropdownProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const { colors } = useTheme();
+
+ // Find selected option from either flat options or groups
+ const allOptions = groups ? groups.flatMap((g) => g.options) : options;
+ const selectedOption = allOptions.find((opt) => opt.value === value);
+
+ const handleSelect = (optionValue: string) => {
+ onValueChange(optionValue);
+ setIsOpen(false);
+ };
+
+ return (
+
+ !disabled && setIsOpen(true)}
+ className={`flex-row items-center justify-between rounded-lg border ${colors.border} ${colors.surface} px-4 py-3 ${
+ disabled ? 'opacity-50' : ''
+ }`}
+ disabled={disabled}>
+
+ {selectedOption?.label || placeholder}
+
+
+
+
+ setIsOpen(false)}>
+ setIsOpen(false)}>
+
+ e.stopPropagation()}
+ className={`max-h-[80%] rounded-xl border ${colors.border} ${colors.surface} shadow-xl`}>
+
+
+ {title}
+ setIsOpen(false)}>
+
+
+
+
+
+ {groups
+ ? // Render grouped options
+ groups.map((group, groupIndex) => (
+ 0 ? 'mt-4' : ''}>
+
+ {group.title}
+
+ {group.options.map((option) => (
+ handleSelect(option.value)}
+ className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
+ option.value === value ? colors.primary : colors.surfaceSecondary
+ }`}>
+
+ {option.label}
+
+
+ ))}
+
+ ))
+ : // Render flat options
+ options.map((option) => (
+ handleSelect(option.value)}
+ className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
+ option.value === value ? colors.primary : colors.surfaceSecondary
+ }`}>
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/reader/apps/mobile/constants/voices.ts b/apps/reader/apps/mobile/constants/voices.ts
new file mode 100644
index 000000000..4173651f4
--- /dev/null
+++ b/apps/reader/apps/mobile/constants/voices.ts
@@ -0,0 +1,386 @@
+export type VoiceProvider = 'google' | 'elevenlabs' | 'openai';
+
+export interface Voice {
+ value: string;
+ label: string;
+ gender: 'male' | 'female';
+ quality: 'premium' | 'neural' | 'wavenet' | 'studio' | 'standard';
+ language: string;
+ provider: VoiceProvider;
+}
+
+export const GERMAN_VOICES: Voice[] = [
+ // Note: Google Chirp HD voices (de-DE-Chirp3-HD-*) are available but not included here
+ // as they require special API access and are significantly more expensive.
+ // Add them if you have access: https://cloud.google.com/text-to-speech/docs/voices
+
+ // Google Cloud TTS - Neural2 voices (most commonly used, good balance of quality and cost)
+ {
+ value: 'de-DE-Neural2-A',
+ label: 'Neural2 A (Weiblich)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Neural2-B',
+ label: 'Neural2 B (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Neural2-C',
+ label: 'Neural2 C (Weiblich)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Neural2-D',
+ label: 'Neural2 D (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Neural2-E',
+ label: 'Neural2 E (Weiblich)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Neural2-F',
+ label: 'Neural2 F (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'google',
+ },
+
+ // Google Cloud TTS - WaveNet voices (high quality, natural sounding)
+ {
+ value: 'de-DE-Wavenet-A',
+ label: 'WaveNet A (Weiblich)',
+ gender: 'female',
+ quality: 'wavenet',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Wavenet-B',
+ label: 'WaveNet B (Männlich)',
+ gender: 'male',
+ quality: 'wavenet',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Wavenet-C',
+ label: 'WaveNet C (Weiblich)',
+ gender: 'female',
+ quality: 'wavenet',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Wavenet-D',
+ label: 'WaveNet D (Männlich)',
+ gender: 'male',
+ quality: 'wavenet',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Wavenet-E',
+ label: 'WaveNet E (Weiblich)',
+ gender: 'female',
+ quality: 'wavenet',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Wavenet-F',
+ label: 'WaveNet F (Männlich)',
+ gender: 'male',
+ quality: 'wavenet',
+ language: 'de-DE',
+ provider: 'google',
+ },
+
+ // Google Cloud TTS - Studio voices (broadcast quality)
+ {
+ value: 'de-DE-Studio-B',
+ label: 'Studio B (Männlich)',
+ gender: 'male',
+ quality: 'studio',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Studio-C',
+ label: 'Studio C (Weiblich)',
+ gender: 'female',
+ quality: 'studio',
+ language: 'de-DE',
+ provider: 'google',
+ },
+
+ // Google Cloud TTS - Standard voices (basic quality, lowest cost)
+ {
+ value: 'de-DE-Standard-A',
+ label: 'Standard A (Weiblich)',
+ gender: 'female',
+ quality: 'standard',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Standard-B',
+ label: 'Standard B (Männlich)',
+ gender: 'male',
+ quality: 'standard',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Standard-C',
+ label: 'Standard C (Weiblich)',
+ gender: 'female',
+ quality: 'standard',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Standard-D',
+ label: 'Standard D (Männlich)',
+ gender: 'male',
+ quality: 'standard',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Standard-E',
+ label: 'Standard E (Weiblich)',
+ gender: 'female',
+ quality: 'standard',
+ language: 'de-DE',
+ provider: 'google',
+ },
+ {
+ value: 'de-DE-Standard-F',
+ label: 'Standard F (Männlich)',
+ gender: 'male',
+ quality: 'standard',
+ language: 'de-DE',
+ provider: 'google',
+ },
+
+ // ElevenLabs voices
+ {
+ value: 'eleven_multilingual_v2',
+ label: 'Rachel (Weiblich)',
+ gender: 'female',
+ quality: 'premium',
+ language: 'de-DE',
+ provider: 'elevenlabs',
+ },
+ {
+ value: 'eleven_multilingual_v1',
+ label: 'Adam (Männlich)',
+ gender: 'male',
+ quality: 'premium',
+ language: 'de-DE',
+ provider: 'elevenlabs',
+ },
+ {
+ value: 'eleven_turbo_v2',
+ label: 'Turbo Rachel (Weiblich) - Low Latency',
+ gender: 'female',
+ quality: 'premium',
+ language: 'de-DE',
+ provider: 'elevenlabs',
+ },
+ {
+ value: 'eleven_monolingual_v1',
+ label: 'Clyde (Männlich)',
+ gender: 'male',
+ quality: 'premium',
+ language: 'de-DE',
+ provider: 'elevenlabs',
+ },
+
+ // OpenAI voices
+ {
+ value: 'alloy',
+ label: 'Alloy (Neutral)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'openai',
+ },
+ {
+ value: 'echo',
+ label: 'Echo (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'openai',
+ },
+ {
+ value: 'fable',
+ label: 'Fable (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'openai',
+ },
+ {
+ value: 'onyx',
+ label: 'Onyx (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'openai',
+ },
+ {
+ value: 'nova',
+ label: 'Nova (Weiblich)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'openai',
+ },
+ {
+ value: 'shimmer',
+ label: 'Shimmer (Weiblich)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'de-DE',
+ provider: 'openai',
+ },
+];
+
+export const ENGLISH_US_VOICES: Voice[] = [
+ // Google Cloud TTS - Neural2 voices
+ {
+ value: 'en-US-Neural2-A',
+ label: 'Neural2 A (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'en-US',
+ provider: 'google',
+ },
+ {
+ value: 'en-US-Neural2-C',
+ label: 'Neural2 C (Weiblich)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'en-US',
+ provider: 'google',
+ },
+ {
+ value: 'en-US-Neural2-D',
+ label: 'Neural2 D (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'en-US',
+ provider: 'google',
+ },
+ {
+ value: 'en-US-Neural2-E',
+ label: 'Neural2 E (Weiblich)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'en-US',
+ provider: 'google',
+ },
+];
+
+export const ENGLISH_GB_VOICES: Voice[] = [
+ // Google Cloud TTS - Neural2 voices
+ {
+ value: 'en-GB-Neural2-A',
+ label: 'Neural2 A (Weiblich)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'en-GB',
+ provider: 'google',
+ },
+ {
+ value: 'en-GB-Neural2-B',
+ label: 'Neural2 B (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'en-GB',
+ provider: 'google',
+ },
+ {
+ value: 'en-GB-Neural2-C',
+ label: 'Neural2 C (Weiblich)',
+ gender: 'female',
+ quality: 'neural',
+ language: 'en-GB',
+ provider: 'google',
+ },
+ {
+ value: 'en-GB-Neural2-D',
+ label: 'Neural2 D (Männlich)',
+ gender: 'male',
+ quality: 'neural',
+ language: 'en-GB',
+ provider: 'google',
+ },
+];
+
+export const ALL_VOICES = [...GERMAN_VOICES, ...ENGLISH_US_VOICES, ...ENGLISH_GB_VOICES];
+
+export const getVoicesByLanguage = (language: string): Voice[] => {
+ return ALL_VOICES.filter((voice) => voice.language === language);
+};
+
+export const getVoiceById = (voiceId: string): Voice | undefined => {
+ if (!voiceId) return undefined;
+
+ try {
+ const allVoices = [...GERMAN_VOICES, ...ENGLISH_US_VOICES, ...ENGLISH_GB_VOICES];
+ return allVoices.find((voice) => voice.value === voiceId);
+ } catch (error) {
+ console.error('Error in getVoiceById:', error);
+ return undefined;
+ }
+};
+
+export const QUALITY_LABELS: Record = {
+ premium: '🌟 Premium',
+ neural: '🧠 Neural',
+ wavenet: '🌊 WaveNet',
+ studio: '🎙️ Studio',
+ standard: '📢 Standard',
+};
+
+export const PROVIDER_LABELS: Record = {
+ google: '🔵 Google Cloud',
+ elevenlabs: '🎯 ElevenLabs',
+ openai: '🤖 OpenAI',
+};
+
+// Backward compatibility: map old voice codes to new voice IDs
+export const LEGACY_VOICE_MAP: Record = {
+ 'de-DE': 'de-DE-Neural2-A',
+ 'en-US': 'en-US-Neural2-A',
+ 'en-GB': 'en-GB-Neural2-A',
+ // Also map old voice IDs that no longer exist
+ 'de-DE-Neural2-G': 'de-DE-Neural2-A',
+ 'de-DE-Neural2-H': 'de-DE-Neural2-B',
+ 'de-DE-Wavenet-G': 'de-DE-Wavenet-A',
+ 'de-DE-Wavenet-H': 'de-DE-Wavenet-B',
+ 'de-DE-Standard-G': 'de-DE-Standard-A',
+ 'de-DE-Standard-H': 'de-DE-Standard-B',
+};
diff --git a/apps/reader/apps/mobile/docs/browser-extension-concept.md b/apps/reader/apps/mobile/docs/browser-extension-concept.md
new file mode 100644
index 000000000..4ce3be91d
--- /dev/null
+++ b/apps/reader/apps/mobile/docs/browser-extension-concept.md
@@ -0,0 +1,116 @@
+# Browser Extension für URL-Extraktion
+
+## Konzept
+Eine Browser Extension kann direkt auf den gerenderten Content zugreifen, nachdem der Nutzer Cookies akzeptiert hat.
+
+## Implementation (Chrome/Safari)
+
+### Manifest.json
+```json
+{
+ "manifest_version": 3,
+ "name": "Reader App Extractor",
+ "permissions": ["activeTab", "clipboardWrite"],
+ "action": {
+ "default_popup": "popup.html"
+ },
+ "content_scripts": [{
+ "matches": [""],
+ "js": ["content.js"]
+ }]
+}
+```
+
+### Content Script
+```javascript
+// content.js
+function extractArticle() {
+ // Nutze Readability direkt im Browser
+ const documentClone = document.cloneNode(true);
+ const reader = new Readability(documentClone);
+ const article = reader.parse();
+
+ if (article) {
+ // Sende an Reader App
+ const readerUrl = `reader-app://add?title=${encodeURIComponent(article.title)}&content=${encodeURIComponent(article.content)}`;
+ window.location.href = readerUrl;
+ }
+}
+```
+
+### Integration in React Native
+```typescript
+// Deep Link Handler
+import { Linking } from 'react-native';
+
+Linking.addEventListener('url', (event) => {
+ const url = new URL(event.url);
+ if (url.protocol === 'reader-app:' && url.pathname === 'add') {
+ const title = url.searchParams.get('title');
+ const content = url.searchParams.get('content');
+ // Erstelle neuen Text
+ }
+});
+```
+
+## iOS Share Extension Alternative
+
+### Info.plist
+```xml
+NSExtension
+
+ NSExtensionAttributes
+
+ NSExtensionActivationRule
+ SUBQUERY(extensionItems, $e, SUBQUERY($e.attachments, $a, $a.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url").@count > 0).@count > 0
+
+
+```
+
+### Share Extension Code
+```swift
+import MobileCoreServices
+
+class ShareViewController: UIViewController {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ if let item = extensionContext?.inputItems.first as? NSExtensionItem,
+ let provider = item.attachments?.first {
+
+ if provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
+ provider.loadItem(forTypeIdentifier: kUTTypeURL as String) { (url, error) in
+ if let shareURL = url as? URL {
+ // Extrahiere Content mit WKWebView
+ self.extractContent(from: shareURL)
+ }
+ }
+ }
+ }
+ }
+
+ func extractContent(from url: URL) {
+ let webView = WKWebView()
+ webView.load(URLRequest(url: url))
+
+ // Nach dem Laden JavaScript ausführen
+ webView.evaluateJavaScript("document.body.innerText") { (result, error) in
+ if let text = result as? String {
+ // Speichere in App Group oder sende an App
+ self.saveToApp(title: url.host ?? "Artikel", content: text, url: url.absoluteString)
+ }
+ }
+ }
+}
+```
+
+## Vorteile
+1. Umgeht alle Cookie-Banner (Nutzer akzeptiert im Browser)
+2. Zugriff auf den vollständig gerenderten Content
+3. Native Integration in iOS/Android Share-Menü
+4. Kein Server-Side Rendering nötig
+
+## Nachteile
+1. Zusätzliche Installation erforderlich
+2. Platform-spezifische Entwicklung
+3. App Store Review Process für Extensions
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/docs/deployment-guide.md b/apps/reader/apps/mobile/docs/deployment-guide.md
new file mode 100644
index 000000000..3a9f7cf76
--- /dev/null
+++ b/apps/reader/apps/mobile/docs/deployment-guide.md
@@ -0,0 +1,270 @@
+# Reader App - Deployment Guide
+
+## Voraussetzungen
+
+1. **Google Cloud Account** mit aktivierter Text-to-Speech API
+2. **Supabase Projekt** mit konfigurierter Datenbank
+3. **Expo Developer Account** (für App Store Deployment)
+
+## 1. Google Cloud Setup
+
+### API Key erstellen
+1. Google Cloud Console → "APIs & Services" → "Credentials"
+2. "Create Credentials" → "API Key"
+3. API Key auf Text-to-Speech API beschränken
+
+### Stimmen konfigurieren
+Die App verwendet Google Neural2 Stimmen:
+- `de-DE-Neural2-A` (Deutsch, weiblich)
+- `en-US-Neural2-A` (Englisch US, männlich)
+- `en-GB-Neural2-A` (Englisch UK, weiblich)
+
+## 2. Supabase Setup
+
+### Datenbank Migrationen
+```bash
+# Migrations ausführen
+supabase migration up
+
+# Oder manuell in SQL Editor:
+# - supabase/migrations/20240116_create_texts_table.sql
+# - supabase/migrations/20240117_create_audio_storage.sql
+```
+
+### Environment Variables
+In Supabase Dashboard → Settings → Edge Functions:
+```
+GOOGLE_TTS_API_KEY=your_google_api_key_here
+```
+
+### Edge Functions deployen
+```bash
+# Supabase CLI installieren
+npm install -g supabase
+
+# Edge Functions deployen
+supabase functions deploy generate-audio
+supabase functions deploy get-audio-url
+```
+
+### Storage Setup
+- Bucket "audio" wird automatisch erstellt
+- RLS Policies sind konfiguriert
+- Benutzer können nur ihre eigenen Audio-Dateien zugreifen
+
+## 3. React Native App Setup
+
+### Environment Variables
+Erstelle `.env.local`:
+```
+EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
+EXPO_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
+```
+
+### Dependencies installieren
+```bash
+npm install
+```
+
+### App konfigurieren
+In `app.json`:
+```json
+{
+ "expo": {
+ "name": "Reader",
+ "slug": "reader",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/icon.png",
+ "userInterfaceStyle": "light",
+ "splash": {
+ "image": "./assets/splash.png",
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff"
+ },
+ "ios": {
+ "supportsTablet": true,
+ "bundleIdentifier": "com.tilljs.reader"
+ },
+ "android": {
+ "adaptiveIcon": {
+ "foregroundImage": "./assets/adaptive-icon.png",
+ "backgroundColor": "#ffffff"
+ }
+ }
+ }
+}
+```
+
+## 4. Development Testing
+
+### Lokal testen
+```bash
+# Development Server starten
+npm start
+
+# iOS Simulator
+npm run ios
+
+# Android Emulator
+npm run android
+```
+
+### Edge Functions testen
+```bash
+# Lokal
+supabase functions serve
+
+# Test Audio Generation
+curl -X POST 'http://localhost:54321/functions/v1/generate-audio' \
+ -H 'Authorization: Bearer YOUR_SUPABASE_JWT' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "textId": "test-id",
+ "content": "Dies ist ein Test für die Audio-Generierung.",
+ "voice": "de-DE",
+ "speed": 1.0
+ }'
+```
+
+## 5. Production Deployment
+
+### EAS Build Setup
+```bash
+# EAS CLI installieren
+npm install -g @expo/eas-cli
+
+# EAS initialisieren
+eas init
+
+# Build konfigurieren
+eas build:configure
+```
+
+### Build Profile (`eas.json`)
+```json
+{
+ "cli": {
+ "version": ">= 0.52.0"
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal"
+ },
+ "preview": {
+ "distribution": "internal"
+ },
+ "production": {}
+ },
+ "submit": {
+ "production": {}
+ }
+}
+```
+
+### Builds erstellen
+```bash
+# Development Build
+eas build --profile development
+
+# Production Build
+eas build --profile production
+```
+
+### App Store Submission
+```bash
+# iOS App Store
+eas submit --platform ios
+
+# Google Play Store
+eas submit --platform android
+```
+
+## 6. Monitoring & Maintenance
+
+### Supabase Dashboard
+- Database Performance
+- Storage Usage
+- Edge Function Logs
+- User Activity
+
+### Google Cloud Monitoring
+- API Usage
+- Kosten überwachen
+- Rate Limits prüfen
+
+### App Analytics
+- Expo Analytics
+- Crashlytics Integration
+- Performance Monitoring
+
+## 7. Kosten-Optimierung
+
+### Google Cloud TTS
+- Erste 1M Zeichen/Monat kostenlos
+- Neural2 Stimmen: $16/1M Zeichen
+- Caching implementiert zur Kostenreduzierung
+
+### Supabase
+- Free Tier: 500MB DB, 1GB Storage
+- Pro Tier: $25/Monat für erweiterte Features
+- Storage: $0.021/GB/Monat
+
+## 8. Sicherheit
+
+### Best Practices
+- API Keys niemals in Client-Code
+- Row Level Security (RLS) aktiviert
+- Signed URLs für Audio-Dateien
+- JWT Token Validation
+
+### Regelmäßige Updates
+- Dependencies aktualisieren
+- Sicherheitspatches einspielen
+- API Key Rotation
+
+## 9. Troubleshooting
+
+### Häufige Probleme
+1. **Audio-Generierung fehlschlägt**
+ - Google Cloud API Key prüfen
+ - Quota-Limits prüfen
+ - Edge Function Logs kontrollieren
+
+2. **Supabase Connection Issues**
+ - Environment Variables prüfen
+ - RLS Policies kontrollieren
+ - Database Connection Pool
+
+3. **Audio-Wiedergabe Probleme**
+ - Expo AV Permissions
+ - File System Access
+ - Audio Format Kompatibilität
+
+### Logs & Debugging
+```bash
+# Supabase Logs
+supabase logs
+
+# Edge Function Logs
+supabase functions logs generate-audio
+
+# App Logs
+expo logs
+```
+
+## 10. Nächste Schritte
+
+### Feature Roadmap
+- Push Notifications
+- Offline-First Synchronisation
+- Cloud Backup
+- Multi-User Support
+- Advanced Audio Controls
+
+### Performance Optimierung
+- Image Optimization
+- Bundle Size Reduction
+- Lazy Loading
+- Background Processing
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/docs/google-cloud-setup.md b/apps/reader/apps/mobile/docs/google-cloud-setup.md
new file mode 100644
index 000000000..aa32f7934
--- /dev/null
+++ b/apps/reader/apps/mobile/docs/google-cloud-setup.md
@@ -0,0 +1,126 @@
+# Google Cloud Text-to-Speech Setup
+
+## 1. Google Cloud Projekt erstellen
+
+1. Besuche die [Google Cloud Console](https://console.cloud.google.com/)
+2. Erstelle ein neues Projekt oder wähle ein existierendes aus
+3. Notiere dir die **Project ID**
+
+## 2. Text-to-Speech API aktivieren
+
+1. Gehe zu "APIs & Services" → "Library"
+2. Suche nach "Cloud Text-to-Speech API"
+3. Klicke auf "Enable"
+
+## 3. Service Account erstellen
+
+1. Gehe zu "IAM & Admin" → "Service Accounts"
+2. Klicke auf "Create Service Account"
+3. Name: `reader-tts-service`
+4. Rolle: `Cloud Text-to-Speech Client`
+5. Klicke auf "Create and Continue"
+
+## 4. API Key erstellen (Alternative)
+
+Für einfache Implementierung können wir einen API Key verwenden:
+
+1. Gehe zu "APIs & Services" → "Credentials"
+2. Klicke auf "Create Credentials" → "API Key"
+3. Kopiere den API Key
+4. Klicke auf "Restrict Key" für Sicherheit
+5. Unter "API restrictions" wähle "Cloud Text-to-Speech API"
+
+## 5. Supabase Environment Variables
+
+Füge folgende Variablen in deine Supabase Edge Functions ein:
+
+```bash
+# In der Supabase Dashboard unter Settings → Edge Functions → Environment Variables
+GOOGLE_TTS_API_KEY=dein_api_key_hier
+```
+
+## 6. Verfügbare Google Cloud TTS Voices
+
+### Deutsch (de-DE)
+#### Neural2 Voices (Empfohlen - beste Balance zwischen Qualität und Kosten)
+- `de-DE-Neural2-A` (weiblich)
+- `de-DE-Neural2-B` (männlich)
+- `de-DE-Neural2-C` (weiblich)
+- `de-DE-Neural2-D` (männlich)
+- `de-DE-Neural2-E` (weiblich)
+- `de-DE-Neural2-F` (männlich)
+
+#### WaveNet Voices (Hochqualitativ)
+- `de-DE-Wavenet-A` (weiblich)
+- `de-DE-Wavenet-B` (männlich)
+- `de-DE-Wavenet-C` (weiblich)
+- `de-DE-Wavenet-D` (männlich)
+- `de-DE-Wavenet-E` (weiblich)
+- `de-DE-Wavenet-F` (männlich)
+
+#### Studio Voices (Broadcast-Qualität)
+- `de-DE-Studio-B` (männlich)
+- `de-DE-Studio-C` (weiblich)
+
+#### Standard Voices (Basis-Qualität, günstigste Option)
+- `de-DE-Standard-A` (weiblich)
+- `de-DE-Standard-B` (männlich)
+- `de-DE-Standard-C` (weiblich)
+- `de-DE-Standard-D` (männlich)
+- `de-DE-Standard-E` (weiblich)
+- `de-DE-Standard-F` (männlich)
+
+### Englisch (US)
+- `en-US-Neural2-A` (männlich)
+- `en-US-Neural2-C` (weiblich)
+- `en-US-Neural2-D` (männlich)
+- `en-US-Neural2-E` (weiblich)
+
+### Englisch (UK)
+- `en-GB-Neural2-A` (weiblich)
+- `en-GB-Neural2-B` (männlich)
+- `en-GB-Neural2-C` (weiblich)
+- `en-GB-Neural2-D` (männlich)
+
+## 7. Kostenschätzung
+
+- **Standard Voices**: $4.00 pro 1 Million Zeichen
+- **Neural2 Voices**: $16.00 pro 1 Million Zeichen
+- **Erstes 1 Million Zeichen pro Monat**: Kostenlos
+
+### Beispielrechnung für 10.000 Zeichen:
+- Standard: $0.04
+- Neural2: $0.16
+
+## 8. Quotas und Limits
+
+- **Requests pro Minute**: 1,000
+- **Requests pro Tag**: 100,000
+- **Zeichen pro Request**: 5,000
+
+## 9. Test der API
+
+```bash
+curl -X POST \
+ -H "Content-Type: application/json" \
+ -d '{
+ "input": {"text": "Hallo Welt, das ist ein Test."},
+ "voice": {"languageCode": "de-DE", "name": "de-DE-Neural2-A"},
+ "audioConfig": {"audioEncoding": "MP3"}
+ }' \
+ "https://texttospeech.googleapis.com/v1/text:synthesize?key=YOUR_API_KEY"
+```
+
+## 10. Nächste Schritte
+
+1. API Key in Supabase Environment Variables eintragen
+2. Edge Functions deployen
+3. Audio-Generierung in der App testen
+4. Monitoring und Logging einrichten
+
+## Sicherheitshinweise
+
+- API Key niemals in Client-Code einbetten
+- Nur über Supabase Edge Functions verwenden
+- Regelmäßige Rotation der API Keys
+- Monitoring der API-Nutzung einrichten
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/docs/url-extraction-options.md b/apps/reader/apps/mobile/docs/url-extraction-options.md
new file mode 100644
index 000000000..660dc18ef
--- /dev/null
+++ b/apps/reader/apps/mobile/docs/url-extraction-options.md
@@ -0,0 +1,133 @@
+# URL-Extraktion Optionen
+
+## Problem
+Viele Webseiten zeigen Cookie-Banner oder andere Overlays, die den eigentlichen Inhalt blockieren.
+
+## Lösungsoptionen
+
+### 1. **ScrapingBee API** (Empfohlen für Production)
+- **Vorteile**:
+ - JavaScript-Rendering
+ - Automatisches Cookie-Banner-Handling
+ - Anti-Bot-Umgehung
+ - Einfache Integration
+- **Nachteile**:
+ - Kostenpflichtig (1000 kostenlose Credits/Monat)
+ - API-Key erforderlich
+- **Setup**:
+ 1. Account bei [ScrapingBee](https://www.scrapingbee.com) erstellen
+ 2. API-Key in Supabase Secrets speichern: `SCRAPINGBEE_API_KEY`
+ 3. Edge Function `extract-url-scrapingbee` deployen
+
+### 2. **Browserless.io**
+- **Vorteile**:
+ - Headless Chrome as a Service
+ - Puppeteer/Playwright kompatibel
+ - Cookie-Banner können programmatisch geklickt werden
+- **Nachteile**:
+ - Kostenpflichtig
+ - Komplexere Integration
+- **Code-Beispiel**:
+```typescript
+const browserlessUrl = `https://chrome.browserless.io/content?token=${BROWSERLESS_TOKEN}`;
+const response = await fetch(browserlessUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ url: targetUrl,
+ waitFor: 3000,
+ scripts: [{
+ content: `document.querySelectorAll('[class*="cookie"] button').forEach(b => b.click())`
+ }]
+ })
+});
+```
+
+### 3. **Reader API von Jina.ai** (Einfachste Lösung)
+- **Vorteile**:
+ - Kostenlos
+ - Keine Registrierung
+ - Einfache Integration
+- **Nachteile**:
+ - Weniger Kontrolle
+ - Rate Limits
+- **Implementierung**:
+```typescript
+const response = await fetch(`https://r.jina.ai/${encodeURIComponent(url)}`, {
+ headers: {
+ 'Accept': 'application/json',
+ 'X-With-Images': 'false'
+ }
+});
+```
+
+### 4. **Client-seitige Lösung** (iOS/Android)
+- **iOS**: SFSafariViewController mit Reader Mode
+- **Android**: Chrome Custom Tabs mit Reader Mode
+- **React Native**:
+```typescript
+import { WebView } from 'react-native-webview';
+
+// Injiziere JavaScript um Content zu extrahieren
+const injectedJS = `
+ // Entferne Cookie-Banner
+ document.querySelectorAll('[class*="cookie"]').forEach(el => el.remove());
+ // Sende Content zurück
+ window.ReactNativeWebView.postMessage(document.body.innerText);
+`;
+```
+
+### 5. **Proxy-Service mit Playwright**
+Eigener Service auf Vercel/Railway:
+```typescript
+// api/extract.ts
+import { chromium } from 'playwright';
+
+export default async function handler(req, res) {
+ const browser = await chromium.launch();
+ const page = await browser.newPage();
+
+ await page.goto(req.query.url);
+
+ // Warte auf Content und klicke Cookie-Banner weg
+ await page.waitForTimeout(2000);
+ await page.click('text=/akzeptieren|accept|agree/i').catch(() => {});
+
+ const content = await page.evaluate(() => {
+ return document.querySelector('article')?.innerText ||
+ document.querySelector('main')?.innerText ||
+ document.body.innerText;
+ });
+
+ await browser.close();
+ res.json({ content });
+}
+```
+
+## Empfehlung
+
+Für schnelle Lösung: **Jina.ai Reader API** einbauen
+Für Production: **ScrapingBee** mit Fallback auf direkte Extraktion
+
+### Quick Implementation mit Jina.ai:
+
+```typescript
+// In extract-url Edge Function
+try {
+ // Versuche zuerst Jina.ai
+ const jinaResponse = await fetch(`https://r.jina.ai/${url}`, {
+ headers: { 'Accept': 'application/json' }
+ });
+
+ if (jinaResponse.ok) {
+ const data = await jinaResponse.json();
+ return new Response(JSON.stringify({
+ title: data.title,
+ content: data.content,
+ // ... weitere Felder
+ }));
+ }
+} catch (e) {
+ // Fallback auf normale Extraktion
+}
+```
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/eslint.config.js b/apps/reader/apps/mobile/eslint.config.js
new file mode 100644
index 000000000..f3084fed2
--- /dev/null
+++ b/apps/reader/apps/mobile/eslint.config.js
@@ -0,0 +1,15 @@
+/* eslint-env node */
+const { defineConfig } = require('eslint/config');
+const expoConfig = require('eslint-config-expo/flat');
+
+module.exports = defineConfig([
+ expoConfig,
+ {
+ ignores: ['dist/*', 'supabase/functions/**/*', '.expo/**/*'],
+ },
+ {
+ rules: {
+ 'react/display-name': 'off',
+ },
+ },
+]);
diff --git a/apps/reader/apps/mobile/global.css b/apps/reader/apps/mobile/global.css
new file mode 100644
index 000000000..b5c61c956
--- /dev/null
+++ b/apps/reader/apps/mobile/global.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/apps/reader/apps/mobile/hooks/useAudio.ts b/apps/reader/apps/mobile/hooks/useAudio.ts
new file mode 100644
index 000000000..58c869c92
--- /dev/null
+++ b/apps/reader/apps/mobile/hooks/useAudio.ts
@@ -0,0 +1,416 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Audio } from 'expo-av';
+import { AudioService, AudioGenerationProgress } from '~/services/audioService';
+import { useTexts } from './useTexts';
+import { useStore } from '~/store/store';
+import { AudioChunk } from '~/types/database';
+
+export interface AudioState {
+ isPlaying: boolean;
+ isLoading: boolean;
+ currentPosition: number;
+ duration: number;
+ currentChunk?: AudioChunk;
+ sound?: Audio.Sound;
+ playbackRate: number;
+}
+
+export const useAudio = () => {
+ const { settings, updateSettings } = useStore();
+ const { updateText } = useTexts();
+
+ const [audioState, setAudioState] = useState({
+ isPlaying: false,
+ isLoading: false,
+ currentPosition: 0,
+ duration: 0,
+ playbackRate: settings.playbackRate || 1.0,
+ });
+
+ const [generationProgress, setGenerationProgress] = useState(
+ null
+ );
+ const [downloadProgress, setDownloadProgress] = useState<{
+ completed: number;
+ total: number;
+ currentChunk: string;
+ } | null>(null);
+
+ const { setCurrentText, setIsPlaying, setCurrentPosition } = useStore();
+ const audioService = AudioService.getInstance();
+
+ // Initialize audio session
+ useEffect(() => {
+ const initializeAudio = async () => {
+ try {
+ await Audio.setAudioModeAsync({
+ allowsRecordingIOS: false,
+ playsInSilentModeIOS: true,
+ shouldDuckAndroid: true,
+ staysActiveInBackground: true,
+ playThroughEarpieceAndroid: false,
+ });
+ } catch (error) {
+ console.error('Error initializing audio:', error);
+ }
+ };
+
+ initializeAudio();
+ }, []);
+
+ // Clean up audio when component unmounts
+ useEffect(() => {
+ return () => {
+ if (audioState.sound) {
+ audioState.sound.unloadAsync();
+ }
+ };
+ }, [audioState.sound]);
+
+ // Generate audio for a text
+ const generateAudio = useCallback(
+ async (
+ textId: string,
+ content: string,
+ voice: string = 'de-DE',
+ speed: number = 1.0,
+ currentText?: any
+ ) => {
+ try {
+ setGenerationProgress({
+ chunksCompleted: 0,
+ totalChunks: 1,
+ currentChunk: 'Starting...',
+ isComplete: false,
+ });
+
+ // Import migration helper
+ const { generateVersionId } = await import('~/utils/audioMigration');
+ const newVersionId = generateVersionId();
+
+ const result = await audioService.generateAudioForText(
+ textId,
+ content,
+ voice,
+ speed,
+ 1000,
+ setGenerationProgress,
+ newVersionId
+ );
+
+ if (!result.success) {
+ throw new Error(result.error);
+ }
+
+ // Get current text to append to audioVersions
+ if (!currentText) {
+ throw new Error('Text must be provided to generate audio');
+ }
+
+ // Import migration helper for existing code
+ const { migrateAudioData } = await import('~/utils/audioMigration');
+
+ // Migrate old data if needed
+ const migratedData = migrateAudioData(currentText.data);
+ const newAudioVersion = {
+ id: newVersionId,
+ chunks: result.chunks || [],
+ settings: { voice, speed },
+ totalSize: result.chunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
+ hasLocalCache: false,
+ createdAt: new Date().toISOString(),
+ };
+
+ // Append new version to audioVersions
+ const updatedAudioVersions = [...(migratedData.audioVersions || []), newAudioVersion];
+
+ // Update text with new audio version
+ await updateText(textId, {
+ data: {
+ ...migratedData,
+ audioVersions: updatedAudioVersions,
+ currentAudioVersion: newVersionId,
+ // Keep legacy audio field for backward compatibility
+ audio: {
+ hasLocalCache: false,
+ chunks: result.chunks || [],
+ totalSize: newAudioVersion.totalSize,
+ lastGenerated: newAudioVersion.createdAt,
+ settings: { voice, speed },
+ },
+ },
+ });
+
+ return result;
+ } catch (error) {
+ console.error('Error generating audio:', error);
+ throw error;
+ } finally {
+ setGenerationProgress(null);
+ }
+ },
+ [audioService, updateText]
+ );
+
+ // Download audio chunks to local storage
+ const downloadAudio = useCallback(
+ async (textId: string, chunks: AudioChunk[]) => {
+ try {
+ setDownloadProgress({
+ completed: 0,
+ total: chunks.length,
+ currentChunk: 'Starting download...',
+ });
+
+ const result = await audioService.downloadAudioChunks(textId, chunks, setDownloadProgress);
+
+ if (!result.success) {
+ throw new Error(result.error);
+ }
+
+ // Update text to mark as locally cached
+ await updateText(textId, {
+ data: {
+ audio: {
+ hasLocalCache: true,
+ chunks: result.localChunks || chunks,
+ totalSize: result.localChunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
+ lastGenerated: new Date().toISOString(),
+ },
+ },
+ });
+
+ return result;
+ } catch (error) {
+ console.error('Error downloading audio:', error);
+ throw error;
+ } finally {
+ setDownloadProgress(null);
+ }
+ },
+ [audioService, updateText]
+ );
+
+ // Play audio from local cache
+ const playAudio = useCallback(
+ async (textId: string, chunks: AudioChunk[], startPosition: number = 0) => {
+ try {
+ setAudioState((prev) => ({ ...prev, isLoading: true }));
+
+ // Stop current audio if playing
+ if (audioState.sound) {
+ audioState.sound.unloadAsync();
+ }
+
+ // Calculate total duration from all chunks
+ const totalDuration = chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000; // Convert to milliseconds
+
+ const result = await audioService.playAudioFromSupabase(textId, chunks, startPosition);
+
+ if (!result.sound) {
+ throw new Error(result.error);
+ }
+
+ const currentChunk = result.chunk;
+ const allChunks = result.chunks || chunks;
+
+ // Set up playback status update
+ result.sound.setOnPlaybackStatusUpdate((status) => {
+ if (status.isLoaded) {
+ // Calculate the actual position across all chunks
+ const chunkPosition = status.positionMillis || 0;
+ const overallPosition = currentChunk
+ ? currentChunk.start + chunkPosition
+ : chunkPosition;
+
+ setAudioState((prev) => ({
+ ...prev,
+ isPlaying: status.isPlaying,
+ currentPosition: overallPosition,
+ duration: totalDuration, // Keep using total duration
+ }));
+
+ // Update global store
+ setIsPlaying(status.isPlaying);
+ setCurrentPosition(overallPosition);
+ }
+ });
+
+ setAudioState((prev) => ({
+ ...prev,
+ sound: result.sound,
+ isLoading: false,
+ isPlaying: true,
+ duration: totalDuration, // Set total duration of all chunks
+ currentChunk: currentChunk,
+ }));
+
+ setCurrentText(textId);
+
+ // Start playing
+ await result.sound.playAsync();
+
+ // Apply saved playback rate
+ if (audioState.playbackRate !== 1.0) {
+ await result.sound.setRateAsync(audioState.playbackRate, true);
+ }
+ } catch (error) {
+ console.error('Error playing audio:', error);
+ setAudioState((prev) => ({ ...prev, isLoading: false }));
+ throw error;
+ }
+ },
+ [
+ audioState.sound,
+ audioState.playbackRate,
+ audioService,
+ setCurrentText,
+ setIsPlaying,
+ setCurrentPosition,
+ ]
+ );
+
+ // Pause audio
+ const pauseAudio = useCallback(async () => {
+ if (audioState.sound) {
+ await audioState.sound.pauseAsync();
+ setAudioState((prev) => ({ ...prev, isPlaying: false }));
+ setIsPlaying(false);
+ }
+ }, [audioState.sound, setIsPlaying]);
+
+ // Resume audio
+ const resumeAudio = useCallback(async () => {
+ if (audioState.sound) {
+ await audioState.sound.playAsync();
+ setAudioState((prev) => ({ ...prev, isPlaying: true }));
+ setIsPlaying(true);
+ }
+ }, [audioState.sound, setIsPlaying]);
+
+ // Stop audio
+ const stopAudio = useCallback(async () => {
+ if (audioState.sound) {
+ await audioState.sound.pauseAsync();
+ await audioState.sound.unloadAsync();
+ setAudioState((prev) => ({
+ ...prev,
+ sound: undefined,
+ isPlaying: false,
+ currentPosition: 0,
+ duration: 0,
+ }));
+ setCurrentText(null);
+ setIsPlaying(false);
+ setCurrentPosition(0);
+ }
+ }, [audioState.sound, setCurrentText, setIsPlaying, setCurrentPosition]);
+
+ // Seek to position
+ const seekTo = useCallback(
+ async (position: number) => {
+ if (audioState.sound) {
+ await audioState.sound.setPositionAsync(position);
+ }
+ },
+ [audioState.sound]
+ );
+
+ // Seek forward by seconds
+ const seekForward = useCallback(
+ async (seconds: number = 15) => {
+ if (audioState.sound && audioState.duration > 0) {
+ const newPosition = Math.min(
+ audioState.currentPosition + seconds * 1000,
+ audioState.duration
+ );
+ await audioState.sound.setPositionAsync(newPosition);
+ }
+ },
+ [audioState.sound, audioState.currentPosition, audioState.duration]
+ );
+
+ // Seek backward by seconds
+ const seekBackward = useCallback(
+ async (seconds: number = 15) => {
+ if (audioState.sound) {
+ const newPosition = Math.max(audioState.currentPosition - seconds * 1000, 0);
+ await audioState.sound.setPositionAsync(newPosition);
+ }
+ },
+ [audioState.sound, audioState.currentPosition]
+ );
+
+ // Set playback speed
+ const setPlaybackSpeed = useCallback(
+ async (rate: number) => {
+ if (audioState.sound) {
+ try {
+ await audioState.sound.setRateAsync(rate, true);
+ setAudioState((prev) => ({ ...prev, playbackRate: rate }));
+ // Persist to store
+ updateSettings({ playbackRate: rate });
+ } catch (error) {
+ console.error('Error setting playback rate:', error);
+ }
+ } else {
+ // If no sound is playing, just update the state for next playback
+ setAudioState((prev) => ({ ...prev, playbackRate: rate }));
+ // Persist to store
+ updateSettings({ playbackRate: rate });
+ }
+ },
+ [audioState.sound, updateSettings]
+ );
+
+ // Clear audio cache
+ const clearCache = useCallback(
+ async (textId: string, chunks: AudioChunk[]) => {
+ await audioService.clearAudioCache(textId, chunks);
+
+ // Update text to mark as not cached
+ await updateText(textId, {
+ data: {
+ audio: {
+ hasLocalCache: false,
+ chunks,
+ totalSize: 0,
+ },
+ },
+ });
+ },
+ [audioService, updateText]
+ );
+
+ // Get cache size
+ const getCacheSize = useCallback(async () => {
+ return await audioService.getCacheSize();
+ }, [audioService]);
+
+ // Check if audio is cached
+ const isAudioCached = useCallback(
+ async (textId: string, chunks: AudioChunk[]) => {
+ return await audioService.isAudioCached(textId, chunks);
+ },
+ [audioService]
+ );
+
+ return {
+ audioState,
+ generationProgress,
+ downloadProgress,
+ generateAudio,
+ downloadAudio,
+ playAudio,
+ pauseAudio,
+ resumeAudio,
+ stopAudio,
+ seekTo,
+ seekForward,
+ seekBackward,
+ setPlaybackSpeed,
+ clearCache,
+ getCacheSize,
+ isAudioCached,
+ };
+};
diff --git a/apps/reader/apps/mobile/hooks/useAuth.ts b/apps/reader/apps/mobile/hooks/useAuth.ts
new file mode 100644
index 000000000..1d7dde399
--- /dev/null
+++ b/apps/reader/apps/mobile/hooks/useAuth.ts
@@ -0,0 +1,113 @@
+import { useEffect, useState } from 'react';
+import { supabase } from '~/utils/supabase';
+import { useStore } from '~/store/store';
+import { Session } from '@supabase/supabase-js';
+
+export const useAuth = () => {
+ const [session, setSession] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const { setUser } = useStore();
+
+ useEffect(() => {
+ // Get initial session
+ supabase.auth.getSession().then(({ data: { session } }) => {
+ setSession(session);
+ if (session?.user) {
+ setUser({
+ id: session.user.id,
+ email: session.user.email!,
+ });
+ }
+ setLoading(false);
+ });
+
+ // Listen for auth changes
+ const {
+ data: { subscription },
+ } = supabase.auth.onAuthStateChange((_event, session) => {
+ setSession(session);
+ if (session?.user) {
+ setUser({
+ id: session.user.id,
+ email: session.user.email!,
+ });
+ } else {
+ setUser(null);
+ }
+ });
+
+ return () => subscription.unsubscribe();
+ }, [setUser]);
+
+ const signUp = async (email: string, password: string) => {
+ try {
+ const { data, error } = await supabase.auth.signUp({
+ email,
+ password,
+ });
+
+ if (error) throw error;
+ return { data, error: null };
+ } catch (error) {
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : 'Fehler bei der Registrierung',
+ };
+ }
+ };
+
+ const signIn = async (email: string, password: string) => {
+ try {
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email,
+ password,
+ });
+
+ if (error) throw error;
+ return { data, error: null };
+ } catch (error) {
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : 'Fehler beim Anmelden',
+ };
+ }
+ };
+
+ const signOut = async () => {
+ try {
+ const { error } = await supabase.auth.signOut();
+ if (error) throw error;
+ return { error: null };
+ } catch (error) {
+ return {
+ error: error instanceof Error ? error.message : 'Fehler beim Abmelden',
+ };
+ }
+ };
+
+ const resetPassword = async (email: string) => {
+ try {
+ const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
+ redirectTo: 'reader://reset-password',
+ });
+
+ if (error) throw error;
+ return { data, error: null };
+ } catch (error) {
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
+ };
+ }
+ };
+
+ return {
+ session,
+ user: session?.user ?? null,
+ loading,
+ signUp,
+ signIn,
+ signOut,
+ resetPassword,
+ };
+};
diff --git a/apps/reader/apps/mobile/hooks/useTexts.ts b/apps/reader/apps/mobile/hooks/useTexts.ts
new file mode 100644
index 000000000..7bf1c2196
--- /dev/null
+++ b/apps/reader/apps/mobile/hooks/useTexts.ts
@@ -0,0 +1,177 @@
+import { useState, useEffect } from 'react';
+import { supabase } from '~/utils/supabase';
+import { Text, TextData } from '~/types/database';
+
+export const useTexts = () => {
+ const [texts, setTexts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchTexts();
+
+ // Realtime Subscription
+ const subscription = supabase
+ .channel('texts_changes')
+ .on(
+ 'postgres_changes',
+ {
+ event: '*',
+ schema: 'public',
+ table: 'texts',
+ },
+ (payload) => {
+ if (payload.eventType === 'INSERT') {
+ // Check if text already exists to avoid duplicates
+ setTexts((prev) => {
+ const exists = prev.some((text) => text.id === payload.new.id);
+ if (exists) return prev;
+ return [payload.new as Text, ...prev];
+ });
+ } else if (payload.eventType === 'UPDATE') {
+ setTexts((prev) =>
+ prev.map((text) => (text.id === payload.new.id ? (payload.new as Text) : text))
+ );
+ } else if (payload.eventType === 'DELETE') {
+ setTexts((prev) => prev.filter((text) => text.id !== payload.old.id));
+ }
+ }
+ )
+ .subscribe();
+
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, []);
+
+ const fetchTexts = async () => {
+ try {
+ setLoading(true);
+ const { data, error } = await supabase
+ .from('texts')
+ .select('*')
+ .order('updated_at', { ascending: false });
+
+ if (error) throw error;
+ setTexts(data || []);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const createText = async (title: string, content: string, initialData?: Partial) => {
+ try {
+ // Get current user
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+
+ if (!user) {
+ throw new Error('Benutzer nicht eingeloggt');
+ }
+
+ const { data, error } = await supabase
+ .from('texts')
+ .insert({
+ title,
+ content,
+ user_id: user.id, // Explicitly set user_id
+ data: {
+ tts: { speed: 1.0, voice: 'de-DE-Neural2-A' },
+ tags: [],
+ stats: { playCount: 0, totalTime: 0, completed: false },
+ ...initialData,
+ },
+ })
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ // Refresh the texts list to ensure we have the latest data
+ await fetchTexts();
+
+ return { data, error: null };
+ } catch (err) {
+ return {
+ data: null,
+ error: err instanceof Error ? err.message : 'Fehler beim Erstellen',
+ };
+ }
+ };
+
+ const updateText = async (textId: string, updates: Partial) => {
+ try {
+ const { data, error } = await supabase
+ .from('texts')
+ .update(updates)
+ .eq('id', textId)
+ .select()
+ .single();
+
+ if (error) throw error;
+ return { data, error: null };
+ } catch (err) {
+ return {
+ data: null,
+ error: err instanceof Error ? err.message : 'Fehler beim Aktualisieren',
+ };
+ }
+ };
+
+ const deleteText = async (textId: string) => {
+ try {
+ const { error } = await supabase.from('texts').delete().eq('id', textId);
+
+ if (error) throw error;
+ return { error: null };
+ } catch (err) {
+ return {
+ error: err instanceof Error ? err.message : 'Fehler beim Löschen',
+ };
+ }
+ };
+
+ const updatePosition = async (textId: string, position: number) => {
+ const text = texts.find((t) => t.id === textId);
+ if (!text) return { error: 'Text nicht gefunden' };
+
+ return updateText(textId, {
+ data: {
+ ...text.data,
+ tts: {
+ ...text.data.tts,
+ lastPosition: position,
+ lastPlayed: new Date().toISOString(),
+ },
+ },
+ });
+ };
+
+ const getTextsByTag = (tag: string) => {
+ return texts.filter((text) => text.data.tags?.includes(tag));
+ };
+
+ const getAllTags = () => {
+ const tagSet = new Set();
+ texts.forEach((text) => {
+ text.data.tags?.forEach((tag) => tagSet.add(tag));
+ });
+ return Array.from(tagSet).sort();
+ };
+
+ return {
+ texts,
+ loading,
+ error,
+ createText,
+ updateText,
+ deleteText,
+ updatePosition,
+ getTextsByTag,
+ getAllTags,
+ refetch: fetchTexts,
+ };
+};
diff --git a/apps/reader/apps/mobile/hooks/useTheme.ts b/apps/reader/apps/mobile/hooks/useTheme.ts
new file mode 100644
index 000000000..ab564b038
--- /dev/null
+++ b/apps/reader/apps/mobile/hooks/useTheme.ts
@@ -0,0 +1,179 @@
+import { useStore } from '~/store/store';
+
+export interface ThemeColors {
+ // Background colors
+ background: string;
+ surface: string;
+ surfaceSecondary: string;
+
+ // Text colors
+ text: string;
+ textSecondary: string;
+ textTertiary: string;
+
+ // Border colors
+ border: string;
+ borderSecondary: string;
+
+ // Primary colors
+ primary: string;
+ primaryLight: string;
+ primaryDark: string;
+
+ // Status colors
+ success: string;
+ successLight: string;
+ warning: string;
+ warningLight: string;
+ error: string;
+ errorLight: string;
+
+ // Tab bar colors
+ tabBarBackground: string;
+ tabBarBorder: string;
+ tabBarActive: string;
+ tabBarInactive: string;
+}
+
+const lightTheme: ThemeColors = {
+ // Background colors
+ background: 'bg-gray-50',
+ surface: 'bg-white',
+ surfaceSecondary: 'bg-gray-100',
+
+ // Text colors
+ text: 'text-gray-900',
+ textSecondary: 'text-gray-600',
+ textTertiary: 'text-gray-500',
+
+ // Border colors
+ border: 'border-gray-200',
+ borderSecondary: 'border-gray-300',
+
+ // Primary colors
+ primary: 'bg-blue-600',
+ primaryLight: 'bg-blue-50',
+ primaryDark: 'bg-blue-700',
+
+ // Status colors
+ success: 'bg-green-600',
+ successLight: 'bg-green-100',
+ warning: 'bg-orange-600',
+ warningLight: 'bg-orange-100',
+ error: 'bg-red-600',
+ errorLight: 'bg-red-50',
+
+ // Tab bar colors
+ tabBarBackground: '#ffffff',
+ tabBarBorder: '#e5e7eb',
+ tabBarActive: '#3B82F6',
+ tabBarInactive: '#6b7280',
+};
+
+const darkTheme: ThemeColors = {
+ // Background colors
+ background: 'bg-gray-900',
+ surface: 'bg-gray-800',
+ surfaceSecondary: 'bg-gray-700',
+
+ // Text colors
+ text: 'text-white',
+ textSecondary: 'text-gray-300',
+ textTertiary: 'text-gray-400',
+
+ // Border colors
+ border: 'border-gray-600',
+ borderSecondary: 'border-gray-500',
+
+ // Primary colors
+ primary: 'bg-blue-600',
+ primaryLight: 'bg-blue-900',
+ primaryDark: 'bg-blue-700',
+
+ // Status colors
+ success: 'bg-green-600',
+ successLight: 'bg-green-900',
+ warning: 'bg-orange-600',
+ warningLight: 'bg-orange-900',
+ error: 'bg-red-600',
+ errorLight: 'bg-red-900',
+
+ // Tab bar colors
+ tabBarBackground: '#1f2937',
+ tabBarBorder: '#374151',
+ tabBarActive: '#3B82F6',
+ tabBarInactive: '#9ca3af',
+};
+
+export const useTheme = () => {
+ const { settings } = useStore();
+ const isDark = settings.theme === 'dark';
+
+ const colors = isDark ? darkTheme : lightTheme;
+
+ return {
+ isDark,
+ colors,
+ theme: settings.theme,
+ };
+};
+
+// Text color utilities
+export const useTextColors = () => {
+ const { colors } = useTheme();
+
+ return {
+ primary: colors.text,
+ secondary: colors.textSecondary,
+ tertiary: colors.textTertiary,
+ primaryText: colors.text.replace('text-', 'text-'),
+ secondaryText: colors.textSecondary.replace('text-', 'text-'),
+ tertiaryText: colors.textTertiary.replace('text-', 'text-'),
+ };
+};
+
+// Background color utilities
+export const useBackgroundColors = () => {
+ const { colors } = useTheme();
+
+ return {
+ main: colors.background,
+ surface: colors.surface,
+ surfaceSecondary: colors.surfaceSecondary,
+ };
+};
+
+// Border color utilities
+export const useBorderColors = () => {
+ const { colors } = useTheme();
+
+ return {
+ main: colors.border,
+ secondary: colors.borderSecondary,
+ };
+};
+
+// Status color utilities
+export const useStatusColors = () => {
+ const { colors } = useTheme();
+
+ return {
+ success: colors.success,
+ successLight: colors.successLight,
+ warning: colors.warning,
+ warningLight: colors.warningLight,
+ error: colors.error,
+ errorLight: colors.errorLight,
+ };
+};
+
+// Primary color utilities
+export const usePrimaryColors = () => {
+ const { colors } = useTheme();
+
+ return {
+ main: colors.primary,
+ light: colors.primaryLight,
+ dark: colors.primaryDark,
+ };
+};
diff --git a/apps/reader/apps/mobile/metro.config.js b/apps/reader/apps/mobile/metro.config.js
new file mode 100644
index 000000000..f4bb1a39d
--- /dev/null
+++ b/apps/reader/apps/mobile/metro.config.js
@@ -0,0 +1,10 @@
+// Learn more https://docs.expo.io/guides/customizing-metro
+const { getDefaultConfig } = require('expo/metro-config');
+
+const { withNativeWind } = require('nativewind/metro');
+
+/** @type {import('expo/metro-config').MetroConfig} */
+
+const config = getDefaultConfig(__dirname);
+
+module.exports = withNativeWind(config, { input: './global.css' });
diff --git a/apps/reader/apps/mobile/nativewind-env.d.ts b/apps/reader/apps/mobile/nativewind-env.d.ts
new file mode 100644
index 000000000..958346287
--- /dev/null
+++ b/apps/reader/apps/mobile/nativewind-env.d.ts
@@ -0,0 +1,3 @@
+///
+
+// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
diff --git a/apps/reader/apps/mobile/package.json b/apps/reader/apps/mobile/package.json
new file mode 100644
index 000000000..b620ba524
--- /dev/null
+++ b/apps/reader/apps/mobile/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "@reader/mobile",
+ "version": "1.0.0",
+ "main": "expo-router/entry",
+ "scripts": {
+ "dev": "expo start",
+ "start": "expo start --dev-client",
+ "ios": "expo run:ios",
+ "android": "expo run:android",
+ "build:dev": "eas build --profile development",
+ "build:preview": "eas build --profile preview",
+ "build:prod": "eas build --profile production",
+ "prebuild": "expo prebuild",
+ "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
+ "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
+ "web": "expo start --web"
+ },
+ "dependencies": {
+ "@expo/vector-icons": "^14.0.0",
+ "@react-native-async-storage/async-storage": "^2.1.2",
+ "@react-navigation/native": "^7.0.3",
+ "@supabase/supabase-js": "^2.38.4",
+ "expo": "^53.0.19",
+ "expo-av": "^15.1.7",
+ "expo-clipboard": "^7.1.5",
+ "expo-constants": "~17.1.4",
+ "expo-dev-client": "~5.2.4",
+ "expo-dev-launcher": "^5.0.17",
+ "expo-file-system": "^18.1.11",
+ "expo-linking": "~7.1.4",
+ "expo-router": "~5.1.3",
+ "expo-status-bar": "~2.2.3",
+ "expo-system-ui": "~5.0.6",
+ "expo-web-browser": "~14.2.0",
+ "nativewind": "latest",
+ "react": "19.0.0",
+ "react-dom": "19.0.0",
+ "react-native": "0.79.5",
+ "react-native-gesture-handler": "~2.24.0",
+ "react-native-reanimated": "~3.17.4",
+ "react-native-safe-area-context": "5.4.0",
+ "react-native-screens": "~4.11.1",
+ "react-native-web": "^0.20.0",
+ "zustand": "^4.5.7"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.20.0",
+ "@types/react": "~19.0.10",
+ "ajv": "^8.12.0",
+ "eslint": "^9.25.1",
+ "eslint-config-expo": "^9.2.0",
+ "eslint-config-prettier": "^10.1.2",
+ "prettier": "^3.2.5",
+ "prettier-plugin-tailwindcss": "^0.5.11",
+ "tailwindcss": "^3.4.0",
+ "typescript": "~5.8.3"
+ },
+ "private": true
+}
diff --git a/apps/reader/apps/mobile/prettier.config.js b/apps/reader/apps/mobile/prettier.config.js
new file mode 100644
index 000000000..17c1b8cc1
--- /dev/null
+++ b/apps/reader/apps/mobile/prettier.config.js
@@ -0,0 +1,10 @@
+module.exports = {
+ printWidth: 100,
+ tabWidth: 2,
+ singleQuote: true,
+ bracketSameLine: true,
+ trailingComma: 'es5',
+
+ plugins: [require.resolve('prettier-plugin-tailwindcss')],
+ tailwindAttributes: ['className'],
+};
diff --git a/apps/reader/apps/mobile/services/audioService.ts b/apps/reader/apps/mobile/services/audioService.ts
new file mode 100644
index 000000000..5b6d8bd0d
--- /dev/null
+++ b/apps/reader/apps/mobile/services/audioService.ts
@@ -0,0 +1,339 @@
+import { supabase } from '~/utils/supabase';
+import * as FileSystem from 'expo-file-system';
+import { Audio } from 'expo-av';
+import { AudioChunk } from '~/types/database';
+import { getVoiceById } from '~/constants/voices';
+
+const AUDIO_DIR = `${FileSystem.documentDirectory}audio/`;
+
+export interface AudioGenerationProgress {
+ chunksCompleted: number;
+ totalChunks: number;
+ currentChunk: string;
+ isComplete: boolean;
+}
+
+export class AudioService {
+ private static instance: AudioService;
+ private supabase = supabase;
+
+ public static getInstance(): AudioService {
+ if (!AudioService.instance) {
+ AudioService.instance = new AudioService();
+ }
+ return AudioService.instance;
+ }
+
+ private constructor() {
+ this.initializeAudioDirectory();
+ }
+
+ private async initializeAudioDirectory(): Promise {
+ try {
+ await FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true });
+ } catch {
+ // Directory might already exist
+ }
+ }
+
+ // Generate audio for a text using Supabase Edge Function
+ async generateAudioForText(
+ textId: string,
+ content: string,
+ voice: string = 'de-DE',
+ speed: number = 1.0,
+ chunkSize: number = 1000,
+ onProgress?: (progress: AudioGenerationProgress) => void,
+ versionId?: string
+ ): Promise<{ success: boolean; error?: string; chunks?: AudioChunk[] }> {
+ try {
+ // Estimate number of chunks for progress tracking
+ const estimatedChunks = Math.ceil(content.length / chunkSize);
+
+ onProgress?.({
+ chunksCompleted: 0,
+ totalChunks: estimatedChunks,
+ currentChunk: 'Starting generation...',
+ isComplete: false,
+ });
+
+ // Determine which provider to use based on the voice
+ let provider = 'google';
+
+ try {
+ const voiceInfo = getVoiceById(voice);
+ if (voiceInfo) {
+ provider = voiceInfo.provider;
+ } else {
+ console.warn(`Voice not found: ${voice}, defaulting to Google provider`);
+ }
+ } catch (error) {
+ console.error('Error getting voice info:', error);
+ // Continue with default Google provider
+ }
+
+ const { data, error } = await supabase.functions.invoke('generate-audio', {
+ body: {
+ textId,
+ content,
+ voice,
+ provider,
+ speed,
+ chunkSize,
+ versionId,
+ },
+ });
+
+ if (error) {
+ throw error;
+ }
+
+ if (!data.success) {
+ throw new Error(data.error || 'Failed to generate audio');
+ }
+
+ onProgress?.({
+ chunksCompleted: data.chunksGenerated,
+ totalChunks: data.chunksGenerated,
+ currentChunk: 'Audio generation complete!',
+ isComplete: true,
+ });
+
+ return {
+ success: true,
+ chunks: data.chunks,
+ };
+ } catch (error) {
+ console.error('Error generating audio:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ }
+
+ // Download audio chunks to local storage
+ async downloadAudioChunks(
+ textId: string,
+ chunks: AudioChunk[],
+ onProgress?: (progress: { completed: number; total: number; currentChunk: string }) => void
+ ): Promise<{ success: boolean; error?: string; localChunks?: AudioChunk[] }> {
+ try {
+ const localChunks: AudioChunk[] = [];
+
+ for (let i = 0; i < chunks.length; i++) {
+ const chunk = chunks[i];
+
+ onProgress?.({
+ completed: i,
+ total: chunks.length,
+ currentChunk: chunk.id,
+ });
+
+ // Get signed URL for the chunk
+ const { data: urlData, error: urlError } = await supabase.functions.invoke(
+ 'get-audio-url',
+ {
+ body: {
+ textId,
+ chunkId: chunk.id,
+ },
+ }
+ );
+
+ if (urlError || !urlData.success) {
+ throw new Error(`Failed to get URL for chunk ${chunk.id}`);
+ }
+
+ // Download the audio file
+ const localFilePath = `${AUDIO_DIR}${textId}_${chunk.id}.mp3`;
+ const downloadResult = await FileSystem.downloadAsync(urlData.url, localFilePath);
+
+ if (downloadResult.status !== 200) {
+ throw new Error(`Failed to download chunk ${chunk.id}`);
+ }
+
+ // Get file info
+ const fileInfo = await FileSystem.getInfoAsync(localFilePath);
+
+ localChunks.push({
+ ...chunk,
+ filename: `${textId}_${chunk.id}.mp3`,
+ size: fileInfo.exists && 'size' in fileInfo ? fileInfo.size : chunk.size,
+ });
+ }
+
+ onProgress?.({
+ completed: chunks.length,
+ total: chunks.length,
+ currentChunk: 'Download complete!',
+ });
+
+ return {
+ success: true,
+ localChunks,
+ };
+ } catch (error) {
+ console.error('Error downloading audio chunks:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ }
+
+ // Play audio directly from Supabase Storage
+ async playAudioFromSupabase(
+ textId: string,
+ chunks: AudioChunk[],
+ startPosition: number = 0
+ ): Promise<{ sound?: Audio.Sound; chunk?: AudioChunk; chunks?: AudioChunk[]; error?: string }> {
+ try {
+ // Calculate chunk positions if not already set
+ let currentPosition = 0;
+ const chunksWithPositions = chunks.map((chunk) => {
+ const chunkStart = currentPosition;
+ const chunkEnd = currentPosition + chunk.duration * 1000; // Convert to milliseconds
+ currentPosition = chunkEnd;
+ return {
+ ...chunk,
+ start: chunk.start ?? chunkStart,
+ end: chunk.end ?? chunkEnd,
+ };
+ });
+
+ // Find the chunk that contains the start position
+ const chunk =
+ chunksWithPositions.find((c) => startPosition >= c.start && startPosition < c.end) ||
+ chunksWithPositions[0]; // Default to first chunk if position not found
+
+ if (!chunk) {
+ throw new Error('No chunk found for the given position');
+ }
+
+ // Get signed URL for the audio chunk
+ const { data: urlData, error: urlError } = await this.supabase.functions.invoke(
+ 'get-audio-url',
+ {
+ body: {
+ textId,
+ chunkId: chunk.id,
+ },
+ }
+ );
+
+ if (urlError || !urlData.success) {
+ throw new Error(`Failed to get audio URL: ${urlError?.message || 'Unknown error'}`);
+ }
+
+ // Create and load the audio from signed URL
+ const { sound } = await Audio.Sound.createAsync({ uri: urlData.url });
+
+ // Calculate position within the chunk
+ const positionWithinChunk = Math.max(0, startPosition - chunk.start);
+ await sound.setPositionAsync(positionWithinChunk);
+
+ return { sound, chunk, chunks: chunksWithPositions };
+ } catch (error) {
+ console.error('Error playing audio from Supabase:', error);
+ return {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ }
+
+ // Play audio from local cache (kept for backward compatibility)
+ async playAudioFromCache(
+ textId: string,
+ chunks: AudioChunk[],
+ startPosition: number = 0
+ ): Promise<{ sound?: Audio.Sound; error?: string }> {
+ try {
+ // Find the chunk that contains the start position
+ const chunk = chunks.find((c) => startPosition >= c.start && startPosition < c.end);
+
+ if (!chunk) {
+ throw new Error('No chunk found for the given position');
+ }
+
+ const filePath = `${AUDIO_DIR}${chunk.filename}`;
+ const fileInfo = await FileSystem.getInfoAsync(filePath);
+
+ if (!fileInfo.exists) {
+ throw new Error('Audio file not found locally');
+ }
+
+ // Create and load the audio
+ const { sound } = await Audio.Sound.createAsync({ uri: filePath });
+
+ // Calculate position within the chunk
+ const chunkProgress = (startPosition - chunk.start) / (chunk.end - chunk.start);
+ const positionMillis = chunkProgress * chunk.duration * 1000;
+
+ await sound.setPositionAsync(positionMillis);
+
+ return { sound };
+ } catch (error) {
+ console.error('Error playing audio from cache:', error);
+ return {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ }
+
+ // Clear local audio cache for a text
+ async clearAudioCache(textId: string, chunks: AudioChunk[]): Promise {
+ try {
+ for (const chunk of chunks) {
+ const filePath = `${AUDIO_DIR}${chunk.filename}`;
+ try {
+ await FileSystem.deleteAsync(filePath);
+ } catch (deleteError) {
+ console.log(`Could not delete ${chunk.filename}:`, deleteError);
+ }
+ }
+ } catch (error) {
+ console.error('Error clearing audio cache:', error);
+ }
+ }
+
+ // Get total cache size
+ async getCacheSize(): Promise {
+ try {
+ const files = await FileSystem.readDirectoryAsync(AUDIO_DIR);
+ let totalSize = 0;
+
+ for (const file of files) {
+ const fileInfo = await FileSystem.getInfoAsync(`${AUDIO_DIR}${file}`);
+ totalSize += fileInfo.exists && 'size' in fileInfo ? fileInfo.size : 0;
+ }
+
+ return totalSize;
+ } catch (error) {
+ console.error('Error calculating cache size:', error);
+ return 0;
+ }
+ }
+
+ // Check if audio is cached locally
+ async isAudioCached(textId: string, chunks: AudioChunk[]): Promise {
+ try {
+ for (const chunk of chunks) {
+ const filePath = `${AUDIO_DIR}${chunk.filename}`;
+ const fileInfo = await FileSystem.getInfoAsync(filePath);
+
+ if (!fileInfo.exists) {
+ return false;
+ }
+ }
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ // Get file path for a chunk
+ getChunkFilePath(textId: string, chunkId: string): string {
+ return `${AUDIO_DIR}${textId}_${chunkId}.mp3`;
+ }
+}
diff --git a/apps/reader/apps/mobile/services/urlExtractorService.ts b/apps/reader/apps/mobile/services/urlExtractorService.ts
new file mode 100644
index 000000000..1c6370eab
--- /dev/null
+++ b/apps/reader/apps/mobile/services/urlExtractorService.ts
@@ -0,0 +1,131 @@
+import { supabase } from '~/utils/supabase';
+
+export interface ExtractedContent {
+ title: string;
+ content: string;
+ excerpt: string;
+ source: string;
+ domain: string;
+ author: string;
+ publishDate: string;
+ wordCount: number;
+ readingTime: number;
+ tags: string[];
+}
+
+export interface ExtractUrlError {
+ message: string;
+ code?: 'INVALID_URL' | 'FETCH_FAILED' | 'EXTRACTION_FAILED' | 'NETWORK_ERROR' | 'UNAUTHORIZED';
+}
+
+class UrlExtractorService {
+ async extractFromUrl(
+ url: string
+ ): Promise<{ data: ExtractedContent | null; error: ExtractUrlError | null }> {
+ try {
+ // Basic URL validation
+ const urlPattern = /^https?:\/\/.+/;
+ if (!urlPattern.test(url)) {
+ return {
+ data: null,
+ error: {
+ message: 'Bitte gib eine gültige URL ein (http:// oder https://)',
+ code: 'INVALID_URL',
+ },
+ };
+ }
+
+ const { data, error } = await supabase.functions.invoke('extract-url', {
+ body: { url },
+ });
+
+ if (error) {
+ console.error('Error extracting URL:', error);
+
+ // Handle specific error cases
+ if (error.message?.includes('Unauthorized')) {
+ return {
+ data: null,
+ error: {
+ message: 'Nicht autorisiert. Bitte melde dich erneut an.',
+ code: 'UNAUTHORIZED',
+ },
+ };
+ }
+
+ if (error.message?.includes('Failed to fetch URL')) {
+ return {
+ data: null,
+ error: {
+ message: 'Die Webseite konnte nicht geladen werden. Überprüfe die URL.',
+ code: 'FETCH_FAILED',
+ },
+ };
+ }
+
+ if (error.message?.includes('Could not extract')) {
+ return {
+ data: null,
+ error: {
+ message:
+ 'Der Text konnte nicht extrahiert werden. Die Seite ist möglicherweise nicht kompatibel.',
+ code: 'EXTRACTION_FAILED',
+ },
+ };
+ }
+
+ return {
+ data: null,
+ error: { message: error.message || 'Ein Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
+ };
+ }
+
+ if (!data) {
+ return {
+ data: null,
+ error: { message: 'Keine Daten empfangen', code: 'EXTRACTION_FAILED' },
+ };
+ }
+
+ return { data: data as ExtractedContent, error: null };
+ } catch (error) {
+ console.error('Unexpected error in extractFromUrl:', error);
+ return {
+ data: null,
+ error: { message: 'Ein unerwarteter Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
+ };
+ }
+ }
+
+ validateUrl(url: string): boolean {
+ try {
+ const urlObj = new URL(url);
+ return ['http:', 'https:'].includes(urlObj.protocol);
+ } catch {
+ return false;
+ }
+ }
+
+ formatExtractedContent(extracted: ExtractedContent): string {
+ // Format the extracted content with title and metadata
+ let formatted = extracted.title + '\n\n';
+
+ if (extracted.author) {
+ formatted += `Von: ${extracted.author}\n`;
+ }
+
+ if (extracted.publishDate) {
+ formatted += `Veröffentlicht: ${extracted.publishDate}\n`;
+ }
+
+ if (extracted.domain) {
+ formatted += `Quelle: ${extracted.domain}\n`;
+ }
+
+ formatted += '\n' + extracted.content;
+
+ return formatted;
+ }
+}
+
+export const urlExtractorService = new UrlExtractorService();
diff --git a/apps/reader/apps/mobile/store/store.ts b/apps/reader/apps/mobile/store/store.ts
new file mode 100644
index 000000000..a0c0a4201
--- /dev/null
+++ b/apps/reader/apps/mobile/store/store.ts
@@ -0,0 +1,84 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+interface User {
+ id: string;
+ email: string;
+}
+
+interface AppState {
+ // User
+ user: User | null;
+ setUser: (user: User | null) => void;
+
+ // Settings
+ settings: {
+ voice: string;
+ speed: number;
+ theme: 'light' | 'dark';
+ playbackRate: number;
+ };
+ updateSettings: (settings: Partial) => void;
+
+ // Audio Player
+ currentTextId: string | null;
+ isPlaying: boolean;
+ currentPosition: number;
+ setCurrentText: (textId: string | null) => void;
+ setIsPlaying: (playing: boolean) => void;
+ setCurrentPosition: (position: number) => void;
+
+ // UI State
+ selectedTags: string[];
+ toggleTag: (tag: string) => void;
+ clearTags: () => void;
+}
+
+export const useStore = create()(
+ persist(
+ (set) => ({
+ // User
+ user: null,
+ setUser: (user) => set({ user }),
+
+ // Settings
+ settings: {
+ voice: 'de-DE-Neural2-A',
+ speed: 1.0,
+ theme: 'light',
+ playbackRate: 1.0,
+ },
+ updateSettings: (newSettings) =>
+ set((state) => ({
+ settings: { ...state.settings, ...newSettings },
+ })),
+
+ // Audio Player
+ currentTextId: null,
+ isPlaying: false,
+ currentPosition: 0,
+ setCurrentText: (textId) => set({ currentTextId: textId, currentPosition: 0 }),
+ setIsPlaying: (playing) => set({ isPlaying: playing }),
+ setCurrentPosition: (position) => set({ currentPosition: position }),
+
+ // UI State
+ selectedTags: [],
+ toggleTag: (tag) =>
+ set((state) => ({
+ selectedTags: state.selectedTags.includes(tag)
+ ? state.selectedTags.filter((t) => t !== tag)
+ : [...state.selectedTags, tag],
+ })),
+ clearTags: () => set({ selectedTags: [] }),
+ }),
+ {
+ name: 'reader-storage',
+ storage: createJSONStorage(() => AsyncStorage),
+ partialize: (state) => ({
+ settings: state.settings,
+ selectedTags: state.selectedTags,
+ }),
+ }
+ )
+);
diff --git a/apps/reader/apps/mobile/supabase/.temp/cli-latest b/apps/reader/apps/mobile/supabase/.temp/cli-latest
new file mode 100644
index 000000000..19a5f69d2
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/.temp/cli-latest
@@ -0,0 +1 @@
+v2.31.4
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/gotrue-version b/apps/reader/apps/mobile/supabase/.temp/gotrue-version
new file mode 100644
index 000000000..debbfeacf
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/.temp/gotrue-version
@@ -0,0 +1 @@
+v2.177.0
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/pooler-url b/apps/reader/apps/mobile/supabase/.temp/pooler-url
new file mode 100644
index 000000000..2b176ad35
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/.temp/pooler-url
@@ -0,0 +1 @@
+postgresql://postgres.tiecnhktvovcqsrnunko:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/postgres-version b/apps/reader/apps/mobile/supabase/.temp/postgres-version
new file mode 100644
index 000000000..8eabea2fd
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/.temp/postgres-version
@@ -0,0 +1 @@
+17.4.1.054
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/project-ref b/apps/reader/apps/mobile/supabase/.temp/project-ref
new file mode 100644
index 000000000..416cb82f4
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/.temp/project-ref
@@ -0,0 +1 @@
+tiecnhktvovcqsrnunko
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/rest-version b/apps/reader/apps/mobile/supabase/.temp/rest-version
new file mode 100644
index 000000000..2392826ee
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/.temp/rest-version
@@ -0,0 +1 @@
+v12.2.3
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/storage-version b/apps/reader/apps/mobile/supabase/.temp/storage-version
new file mode 100644
index 000000000..04f88252e
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/.temp/storage-version
@@ -0,0 +1 @@
+custom-metadata
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/functions/extract-url-scrapingbee/index.ts b/apps/reader/apps/mobile/supabase/functions/extract-url-scrapingbee/index.ts
new file mode 100644
index 000000000..fc3168f38
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/functions/extract-url-scrapingbee/index.ts
@@ -0,0 +1,299 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
+import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+serve(async (req) => {
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers: corsHeaders });
+ }
+
+ try {
+ const authHeader = req.headers.get('Authorization');
+ if (!authHeader) {
+ return new Response(JSON.stringify({ error: 'No authorization header' }), {
+ status: 401,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ const supabaseClient = createClient(
+ Deno.env.get('SUPABASE_URL') ?? '',
+ Deno.env.get('SUPABASE_ANON_KEY') ?? '',
+ {
+ global: {
+ headers: { Authorization: authHeader },
+ },
+ }
+ );
+
+ const {
+ data: { user },
+ error: authError,
+ } = await supabaseClient.auth.getUser();
+
+ if (authError || !user) {
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ const { url } = await req.json();
+
+ if (!url) {
+ return new Response(JSON.stringify({ error: 'URL is required' }), {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Validate URL
+ let validatedUrl;
+ try {
+ validatedUrl = new URL(url);
+ if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
+ throw new Error('Invalid protocol');
+ }
+ } catch {
+ return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Use ScrapingBee API - requires API key in environment
+ const scrapingBeeApiKey = Deno.env.get('SCRAPINGBEE_API_KEY');
+ if (!scrapingBeeApiKey) {
+ console.error('SCRAPINGBEE_API_KEY not configured, falling back to direct fetch');
+ // Fallback to direct fetch if API key not configured
+ return fallbackExtraction(validatedUrl, corsHeaders);
+ }
+
+ // ScrapingBee API request
+ const scrapingBeeUrl = new URL('https://app.scrapingbee.com/api/v1/');
+ scrapingBeeUrl.searchParams.append('api_key', scrapingBeeApiKey);
+ scrapingBeeUrl.searchParams.append('url', validatedUrl.toString());
+ scrapingBeeUrl.searchParams.append('render_js', 'true'); // Render JavaScript
+ scrapingBeeUrl.searchParams.append('wait', '3000'); // Wait 3s for content to load
+ scrapingBeeUrl.searchParams.append('block_ads', 'true'); // Block ads
+ scrapingBeeUrl.searchParams.append('stealth_mode', 'true'); // Bypass anti-bot measures
+
+ // Custom JavaScript to remove cookie banners
+ const jsScript = `
+ // Remove cookie banners
+ const selectors = [
+ '[class*="cookie"]', '[id*="cookie"]',
+ '[class*="consent"]', '[id*="consent"]',
+ '[class*="gdpr"]', '[id*="gdpr"]',
+ '.privacy-banner', '#privacy-banner'
+ ];
+ selectors.forEach(sel => {
+ document.querySelectorAll(sel).forEach(el => {
+ if (el.textContent.toLowerCase().includes('cookie') ||
+ el.textContent.toLowerCase().includes('consent')) {
+ el.remove();
+ }
+ });
+ });
+ // Click accept buttons if needed
+ const acceptButtons = document.querySelectorAll('button, a');
+ acceptButtons.forEach(btn => {
+ const text = btn.textContent.toLowerCase();
+ if ((text.includes('accept') || text.includes('akzeptieren')) &&
+ (text.includes('cookie') || text.includes('all'))) {
+ btn.click();
+ }
+ });
+ `;
+ scrapingBeeUrl.searchParams.append('js_scenario', btoa(jsScript));
+
+ const response = await fetch(scrapingBeeUrl.toString());
+
+ if (!response.ok) {
+ console.error('ScrapingBee error:', response.status, await response.text());
+ return fallbackExtraction(validatedUrl, corsHeaders);
+ }
+
+ const html = await response.text();
+
+ // Parse and extract content
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+
+ if (!doc) {
+ return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
+ status: 500,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Use Readability to extract article content
+ const reader = new Readability(doc);
+ const article = reader.parse();
+
+ if (!article || article.textContent.length < 200) {
+ // Try manual extraction
+ return manualExtraction(doc, validatedUrl, corsHeaders);
+ }
+
+ // Extract metadata
+ const metadata = extractMetadata(doc);
+ const tags = generateTags(metadata.keywords);
+
+ // Clean content
+ const cleanedContent = article.textContent
+ .replace(/\s+/g, ' ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+
+ return new Response(
+ JSON.stringify({
+ title: article.title || 'Untitled',
+ content: cleanedContent,
+ excerpt: article.excerpt || metadata.description || '',
+ source: validatedUrl.toString(),
+ domain: validatedUrl.hostname,
+ author: article.byline || metadata.author || '',
+ publishDate: metadata.publishDate || '',
+ wordCount: cleanedContent.split(/\s+/).length,
+ readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200),
+ tags,
+ }),
+ {
+ status: 200,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ }
+ );
+ } catch (error) {
+ console.error('Extract URL error:', error);
+ return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
+ status: 500,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+});
+
+// Helper functions
+async function fallbackExtraction(url: URL, corsHeaders: any) {
+ // Original extraction logic as fallback
+ const response = await fetch(url.toString(), {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ },
+ });
+
+ if (!response.ok) {
+ return new Response(JSON.stringify({ error: `Failed to fetch URL: ${response.status}` }), {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ const html = await response.text();
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+
+ return manualExtraction(doc!, url, corsHeaders);
+}
+
+function manualExtraction(doc: any, url: URL, corsHeaders: any) {
+ let content = '';
+ let title = '';
+
+ // Find title
+ const titleElement = doc.querySelector('h1') || doc.querySelector('title');
+ if (titleElement) {
+ title = titleElement.textContent?.trim() || '';
+ }
+
+ // Find content
+ const contentSelectors = [
+ 'main',
+ 'article',
+ '[role="main"]',
+ '.content',
+ '#content',
+ '.post',
+ '.entry-content',
+ '.article-content',
+ ];
+
+ for (const selector of contentSelectors) {
+ const element = doc.querySelector(selector);
+ if (element && element.textContent) {
+ content = element.textContent.trim();
+ break;
+ }
+ }
+
+ // Get paragraphs
+ if (!content || content.length < 200) {
+ const paragraphs = doc.querySelectorAll('p');
+ const texts: string[] = [];
+ paragraphs.forEach((p: any) => {
+ const text = p.textContent?.trim();
+ if (text && text.length > 50) {
+ texts.push(text);
+ }
+ });
+ content = texts.join('\n\n');
+ }
+
+ if (!content || content.length < 100) {
+ return new Response(JSON.stringify({ error: 'Could not extract meaningful content' }), {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ return new Response(
+ JSON.stringify({
+ title: title || 'Untitled',
+ content: content,
+ excerpt: content.substring(0, 200),
+ source: url.toString(),
+ domain: url.hostname,
+ author: '',
+ publishDate: '',
+ wordCount: content.split(/\s+/).length,
+ readingTime: Math.ceil(content.split(/\s+/).length / 200),
+ tags: [],
+ }),
+ {
+ status: 200,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ }
+ );
+}
+
+function extractMetadata(doc: any) {
+ const metadata: Record = {};
+ const metaTags = doc.querySelectorAll('meta');
+
+ metaTags.forEach((meta: any) => {
+ const name = meta.getAttribute('name') || meta.getAttribute('property');
+ const content = meta.getAttribute('content');
+
+ if (name && content) {
+ if (name.includes('author')) metadata.author = content;
+ if (name.includes('description')) metadata.description = content;
+ if (name.includes('keywords')) metadata.keywords = content;
+ if (name.includes('publish')) metadata.publishDate = content;
+ }
+ });
+
+ return metadata;
+}
+
+function generateTags(keywords?: string): string[] {
+ if (!keywords) return [];
+ return keywords
+ .split(',')
+ .map((k) => k.trim())
+ .filter((k) => k.length > 0)
+ .slice(0, 5);
+}
diff --git a/apps/reader/apps/mobile/supabase/functions/extract-url/index.ts b/apps/reader/apps/mobile/supabase/functions/extract-url/index.ts
new file mode 100644
index 000000000..1d78a88a7
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/functions/extract-url/index.ts
@@ -0,0 +1,332 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
+import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+serve(async (req) => {
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers: corsHeaders });
+ }
+
+ try {
+ const authHeader = req.headers.get('Authorization');
+ if (!authHeader) {
+ return new Response(JSON.stringify({ error: 'No authorization header' }), {
+ status: 401,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ const supabaseClient = createClient(
+ Deno.env.get('SUPABASE_URL') ?? '',
+ Deno.env.get('SUPABASE_ANON_KEY') ?? '',
+ {
+ global: {
+ headers: { Authorization: authHeader },
+ },
+ }
+ );
+
+ const {
+ data: { user },
+ error: authError,
+ } = await supabaseClient.auth.getUser();
+
+ if (authError || !user) {
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ const { url } = await req.json();
+
+ if (!url) {
+ return new Response(JSON.stringify({ error: 'URL is required' }), {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Validate URL
+ let validatedUrl;
+ try {
+ validatedUrl = new URL(url);
+ if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
+ throw new Error('Invalid protocol');
+ }
+ } catch {
+ return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Try Jina.ai Reader API for better extraction
+ try {
+ const jinaUrl = `https://r.jina.ai/${validatedUrl.toString()}`;
+ const jinaResponse = await fetch(jinaUrl, {
+ headers: {
+ Accept: 'text/plain',
+ 'X-Return-Format': 'text',
+ },
+ signal: AbortSignal.timeout(15000), // 15 second timeout
+ });
+
+ if (jinaResponse.ok) {
+ const content = await jinaResponse.text();
+
+ // Check if we got meaningful content (not just cookie banner)
+ if (
+ content &&
+ content.length > 500 &&
+ !content.toLowerCase().includes('cookies zustimmen') &&
+ !content.toLowerCase().includes('cookie banner')
+ ) {
+ // Extract title from content (usually first line)
+ const lines = content.split('\n').filter((line) => line.trim());
+ const title = lines[0] || 'Untitled';
+ const actualContent = lines.slice(1).join('\n\n');
+
+ return new Response(
+ JSON.stringify({
+ title: title.substring(0, 200), // Limit title length
+ content: actualContent || content,
+ excerpt: actualContent.substring(0, 200),
+ source: validatedUrl.toString(),
+ domain: validatedUrl.hostname,
+ author: '',
+ publishDate: '',
+ wordCount: content.split(/\s+/).length,
+ readingTime: Math.ceil(content.split(/\s+/).length / 200),
+ tags: [],
+ }),
+ {
+ status: 200,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ }
+ );
+ }
+ }
+ } catch (jinaError) {
+ console.log('Jina.ai extraction failed:', jinaError);
+ }
+
+ // Fallback to direct webpage fetch
+ const response = await fetch(validatedUrl.toString(), {
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
+ 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
+ },
+ signal: AbortSignal.timeout(15000), // 15 second timeout
+ });
+
+ if (!response.ok) {
+ return new Response(
+ JSON.stringify({ error: `Failed to fetch URL: ${response.status} ${response.statusText}` }),
+ {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ }
+ );
+ }
+
+ const html = await response.text();
+
+ // Parse HTML and extract content
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+
+ if (!doc) {
+ return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
+ status: 500,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Try to remove common cookie banners and overlays
+ const elementsToRemove = [
+ // Cookie banners
+ '[class*="cookie"]',
+ '[id*="cookie"]',
+ '[class*="consent"]',
+ '[id*="consent"]',
+ '[class*="gdpr"]',
+ '[id*="gdpr"]',
+ '[class*="privacy"]',
+ '[id*="privacy-banner"]',
+ // Overlays
+ '[class*="overlay"]',
+ '[class*="modal"]',
+ '[class*="popup"]',
+ // Specific patterns
+ '.cookie-banner',
+ '#cookie-banner',
+ '.privacy-banner',
+ '#privacy-banner',
+ ];
+
+ elementsToRemove.forEach((selector) => {
+ try {
+ const elements = doc.querySelectorAll(selector);
+ elements.forEach((el: any) => {
+ // Only remove if it looks like a banner/overlay (not main content)
+ const text = el.textContent || '';
+ if (
+ text.toLowerCase().includes('cookie') ||
+ text.toLowerCase().includes('datenschutz') ||
+ text.toLowerCase().includes('privacy') ||
+ text.toLowerCase().includes('consent')
+ ) {
+ el.remove();
+ }
+ });
+ } catch (e) {
+ // Ignore selector errors
+ }
+ });
+
+ // Use Readability to extract article content
+ const reader = new Readability(doc);
+ const article = reader.parse();
+
+ if (!article) {
+ // Fallback: Try to extract content manually
+ let content = '';
+ let title = '';
+
+ // Try to find title
+ const titleElement = doc.querySelector('h1') || doc.querySelector('title');
+ if (titleElement) {
+ title = titleElement.textContent?.trim() || '';
+ }
+
+ // Try to find main content areas
+ const contentSelectors = [
+ 'main',
+ 'article',
+ '[role="main"]',
+ '.content',
+ '#content',
+ '.post',
+ '.entry-content',
+ '.article-content',
+ '.main-content',
+ ];
+
+ for (const selector of contentSelectors) {
+ const element = doc.querySelector(selector);
+ if (element && element.textContent) {
+ content = element.textContent.trim();
+ break;
+ }
+ }
+
+ // If still no content, get all paragraphs
+ if (!content) {
+ const paragraphs = doc.querySelectorAll('p');
+ const texts: string[] = [];
+ paragraphs.forEach((p: any) => {
+ const text = p.textContent?.trim();
+ if (text && text.length > 50) {
+ // Filter out short paragraphs
+ texts.push(text);
+ }
+ });
+ content = texts.join('\n\n');
+ }
+
+ if (!content || content.length < 100) {
+ return new Response(
+ JSON.stringify({ error: 'Could not extract meaningful article content' }),
+ {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ }
+ );
+ }
+
+ // Create a pseudo-article object
+ return new Response(
+ JSON.stringify({
+ title: title || 'Untitled',
+ content: content,
+ excerpt: content.substring(0, 200),
+ source: validatedUrl.toString(),
+ domain: validatedUrl.hostname,
+ author: '',
+ publishDate: '',
+ wordCount: content.split(/\s+/).length,
+ readingTime: Math.ceil(content.split(/\s+/).length / 200),
+ tags: [],
+ }),
+ {
+ status: 200,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ }
+ );
+ }
+
+ // Extract additional metadata
+ const metaTags = doc.querySelectorAll('meta');
+ const metadata: Record = {};
+
+ metaTags.forEach((meta: any) => {
+ const name = meta.getAttribute('name') || meta.getAttribute('property');
+ const content = meta.getAttribute('content');
+
+ if (name && content) {
+ if (name.includes('author')) metadata.author = content;
+ if (name.includes('description')) metadata.description = content;
+ if (name.includes('keywords')) metadata.keywords = content;
+ if (name.includes('publish')) metadata.publishDate = content;
+ }
+ });
+
+ // Generate tags from keywords if available
+ const tags = metadata.keywords
+ ? metadata.keywords
+ .split(',')
+ .map((k) => k.trim())
+ .filter((k) => k.length > 0)
+ .slice(0, 5)
+ : [];
+
+ // Clean and format the extracted text
+ const cleanedContent = article.textContent
+ .replace(/\s+/g, ' ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+
+ return new Response(
+ JSON.stringify({
+ title: article.title || 'Untitled',
+ content: cleanedContent,
+ excerpt: article.excerpt || metadata.description || '',
+ source: validatedUrl.toString(),
+ domain: validatedUrl.hostname,
+ author: article.byline || metadata.author || '',
+ publishDate: metadata.publishDate || '',
+ wordCount: cleanedContent.split(/\s+/).length,
+ readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200), // Assuming 200 words per minute
+ tags,
+ }),
+ {
+ status: 200,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ }
+ );
+ } catch (error) {
+ console.error('Extract URL error:', error);
+ return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
+ status: 500,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+});
diff --git a/apps/reader/apps/mobile/supabase/functions/generate-audio/index.ts b/apps/reader/apps/mobile/supabase/functions/generate-audio/index.ts
new file mode 100644
index 000000000..b1ec313ee
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/functions/generate-audio/index.ts
@@ -0,0 +1,498 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+interface AudioRequest {
+ textId: string;
+ content: string;
+ voice: string;
+ provider: 'google' | 'elevenlabs' | 'openai';
+ speed: number;
+ chunkSize?: number;
+ versionId?: string;
+}
+
+interface AudioChunk {
+ id: string;
+ start: number;
+ end: number;
+ content: string;
+}
+
+serve(async (req) => {
+ // Handle CORS preflight requests
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers: corsHeaders });
+ }
+
+ try {
+ // Parse request first to get provider
+ const requestData: AudioRequest = await req.json();
+ const { provider = 'google' } = requestData;
+
+ // Check required environment variables based on provider
+ let apiKeyPresent = false;
+ let missingKeyMessage = '';
+
+ switch (provider) {
+ case 'google':
+ apiKeyPresent = !!Deno.env.get('GOOGLE_TTS_API_KEY');
+ missingKeyMessage = 'Missing GOOGLE_TTS_API_KEY environment variable';
+ break;
+ case 'elevenlabs':
+ apiKeyPresent = !!Deno.env.get('ELEVENLABS_API_KEY');
+ missingKeyMessage = 'Missing ELEVENLABS_API_KEY environment variable';
+ break;
+ case 'openai':
+ apiKeyPresent = !!Deno.env.get('OPENAI_API_KEY');
+ missingKeyMessage = 'Missing OPENAI_API_KEY environment variable';
+ break;
+ }
+
+ if (!apiKeyPresent) {
+ console.error(missingKeyMessage);
+ return new Response(JSON.stringify({ error: 'TTS service not configured' }), {
+ status: 500,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Initialize Supabase client
+ const supabaseClient = createClient(
+ Deno.env.get('SUPABASE_URL') ?? '',
+ Deno.env.get('SUPABASE_ANON_KEY') ?? '',
+ {
+ global: {
+ headers: { Authorization: req.headers.get('Authorization')! },
+ },
+ }
+ );
+
+ // Get user from JWT token
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser();
+
+ if (!user) {
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ const {
+ textId,
+ content,
+ voice,
+ speed,
+ chunkSize = 1000,
+ versionId,
+ } = requestData;
+
+ // Validate input
+ if (!textId || !content) {
+ return new Response(JSON.stringify({ error: 'Missing required fields' }), {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Split text into chunks
+ const chunks: AudioChunk[] = [];
+ for (let i = 0; i < content.length; i += chunkSize) {
+ chunks.push({
+ id: `chunk-${chunks.length}`,
+ start: i,
+ end: Math.min(i + chunkSize, content.length),
+ content: content.slice(i, Math.min(i + chunkSize, content.length)),
+ });
+ }
+
+ // Generate audio based on the provider
+ let audioResult;
+
+ switch (provider) {
+ case 'elevenlabs':
+ audioResult = await generateElevenLabsTTS(chunks, voice, speed);
+ break;
+ case 'openai':
+ audioResult = await generateOpenAITTS(chunks, voice, speed);
+ break;
+ case 'google':
+ default:
+ audioResult = await generateGoogleTTS(chunks, voice, speed);
+ break;
+ }
+
+ const { audioChunks, totalSize } = audioResult;
+
+ // Store audio chunks in Supabase Storage
+ const storedChunks = [];
+ for (const chunkData of audioChunks) {
+ try {
+ // Use versionId in path if provided, otherwise use default path
+ const fileName = versionId
+ ? `${user.id}/${textId}/${versionId}/${chunkData.id}.mp3`
+ : `${user.id}/${textId}/${chunkData.id}.mp3`;
+
+ const { error: uploadError } = await supabaseClient.storage
+ .from('audio')
+ .upload(fileName, chunkData.audioBuffer, {
+ contentType: 'audio/mpeg',
+ upsert: true,
+ });
+
+ if (uploadError) {
+ console.error('Upload error:', uploadError);
+ throw uploadError;
+ }
+
+ // Create audio chunk metadata for storage
+ storedChunks.push({
+ id: chunkData.id,
+ start: chunkData.start,
+ end: chunkData.end,
+ filename: fileName,
+ size: chunkData.size,
+ duration: chunkData.duration,
+ createdAt: new Date().toISOString(),
+ });
+ } catch (error) {
+ console.error(`Error storing chunk ${chunkData.id}:`, error);
+ // Continue with other chunks, but log the error
+ }
+ }
+
+ // Update text record with audio metadata
+ const { error: updateError } = await supabaseClient
+ .from('texts')
+ .update({
+ data: {
+ audio: {
+ hasLocalCache: false, // Will be set to true when downloaded to device
+ chunks: storedChunks,
+ totalSize,
+ lastGenerated: new Date().toISOString(),
+ settings: { voice, speed, provider },
+ },
+ },
+ })
+ .eq('id', textId)
+ .eq('user_id', user.id);
+
+ if (updateError) {
+ throw updateError;
+ }
+
+ return new Response(
+ JSON.stringify({
+ success: true,
+ chunksGenerated: storedChunks.length,
+ totalSize,
+ chunks: storedChunks,
+ provider,
+ }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ }
+ );
+ } catch (error) {
+ console.error('Error in generate-audio function:', error);
+ return new Response(JSON.stringify({ error: error.message }), {
+ status: 500,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+});
+
+function extractLanguageCode(voiceId: string): string {
+ // Extract language code from voice ID (e.g., "de-DE" from "de-DE-Neural2-G")
+ const parts = voiceId.split('-');
+ if (parts.length >= 2) {
+ return `${parts[0]}-${parts[1]}`;
+ }
+ return 'de-DE'; // Default fallback
+}
+
+function getVoiceName(voiceId: string): string {
+ // If it's already a full voice ID (contains more than just language code), return it
+ if (voiceId.includes('-') && voiceId.split('-').length > 2) {
+ return voiceId;
+ }
+
+ // Legacy support: map old language codes to default voices
+ const legacyVoiceMap: Record = {
+ 'de-DE': 'de-DE-Neural2-A',
+ 'en-US': 'en-US-Neural2-A',
+ 'en-GB': 'en-GB-Neural2-A',
+ };
+
+ return legacyVoiceMap[voiceId] || 'de-DE-Neural2-A';
+}
+
+function estimateAudioDuration(text: string, speed: number): number {
+ // Rough estimate: 150 words per minute for normal speech
+ const wordsPerMinute = 150 * speed;
+ const wordCount = text.split(/\s+/).length;
+ return Math.ceil((wordCount / wordsPerMinute) * 60);
+}
+
+// Google Cloud TTS Implementation
+async function generateGoogleTTS(chunks: AudioChunk[], voice: string, speed: number) {
+ const googleApiKey = Deno.env.get('GOOGLE_TTS_API_KEY');
+ if (!googleApiKey) {
+ throw new Error('Google TTS API key not configured');
+ }
+
+ const audioChunks = [];
+ let totalSize = 0;
+
+ for (const chunk of chunks) {
+ let retries = 0;
+ const maxRetries = 3;
+ let delay = 1000; // Start with 1 second delay
+
+ while (retries < maxRetries) {
+ try {
+ const ttsResponse = await fetch(
+ `https://texttospeech.googleapis.com/v1/text:synthesize?key=${googleApiKey}`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ input: { text: chunk.content },
+ voice: {
+ languageCode: extractLanguageCode(voice),
+ name: getVoiceName(voice),
+ },
+ audioConfig: {
+ audioEncoding: 'MP3',
+ speakingRate: speed,
+ pitch: 0,
+ volumeGainDb: 0,
+ },
+ }),
+ }
+ );
+
+ if (ttsResponse.status === 429 || ttsResponse.status === 503) {
+ retries++;
+ if (retries < maxRetries) {
+ console.log(`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`);
+ await new Promise(resolve => setTimeout(resolve, delay));
+ delay *= 2; // Exponential backoff
+ continue;
+ } else {
+ throw new Error(`Google TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`);
+ }
+ }
+
+ if (!ttsResponse.ok) {
+ const errorBody = await ttsResponse.text();
+ console.error('Google TTS API Error:', {
+ status: ttsResponse.status,
+ body: errorBody,
+ });
+ throw new Error(`Google TTS error: ${ttsResponse.status}`);
+ }
+
+ const ttsData = await ttsResponse.json();
+ const audioContent = ttsData.audioContent;
+ const audioBuffer = Uint8Array.from(atob(audioContent), (c) => c.charCodeAt(0));
+ const audioSize = audioBuffer.length;
+
+ totalSize += audioSize;
+ audioChunks.push({
+ id: chunk.id,
+ start: chunk.start,
+ end: chunk.end,
+ audioBuffer,
+ size: audioSize,
+ duration: estimateAudioDuration(chunk.content, speed),
+ });
+ break; // Success, exit retry loop
+ } catch (error) {
+ retries++;
+ console.error(`Error processing Google TTS chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`, error);
+ if (retries >= maxRetries) {
+ throw error; // Re-throw after all retries exhausted
+ }
+ await new Promise(resolve => setTimeout(resolve, delay));
+ delay *= 2; // Exponential backoff for other errors too
+ }
+ }
+ }
+
+ return { audioChunks, totalSize };
+}
+
+// ElevenLabs TTS Implementation
+async function generateElevenLabsTTS(chunks: AudioChunk[], voice: string, speed: number) {
+ const elevenLabsApiKey = Deno.env.get('ELEVENLABS_API_KEY');
+ if (!elevenLabsApiKey) {
+ throw new Error('ElevenLabs API key not configured');
+ }
+
+ const audioChunks = [];
+ let totalSize = 0;
+
+ // Map voice IDs to ElevenLabs voice IDs
+ const voiceMapping: Record = {
+ eleven_multilingual_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel
+ eleven_multilingual_v1: 'pNInz6obpgDQGcFmaJgB', // Adam
+ eleven_turbo_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel Turbo
+ eleven_monolingual_v1: '2EiwWnXFnvU5JabPnv8n', // Clyde
+ };
+
+ const elevenLabsVoiceId = voiceMapping[voice] || '21m00Tcm4TlvDq8ikWAM';
+
+ for (const chunk of chunks) {
+ let retries = 0;
+ const maxRetries = 3;
+ let delay = 1000; // Start with 1 second delay
+
+ while (retries < maxRetries) {
+ try {
+ const ttsResponse = await fetch(
+ `https://api.elevenlabs.io/v1/text-to-speech/${elevenLabsVoiceId}`,
+ {
+ method: 'POST',
+ headers: {
+ 'xi-api-key': elevenLabsApiKey,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ text: chunk.content,
+ model_id: voice.includes('turbo') ? 'eleven_turbo_v2' : 'eleven_multilingual_v2',
+ voice_settings: {
+ stability: 0.5,
+ similarity_boost: 0.5,
+ style: 0.5,
+ use_speaker_boost: true,
+ },
+ }),
+ }
+ );
+
+ if (ttsResponse.status === 429 || ttsResponse.status === 503) {
+ retries++;
+ if (retries < maxRetries) {
+ console.log(`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`);
+ await new Promise(resolve => setTimeout(resolve, delay));
+ delay *= 2; // Exponential backoff
+ continue;
+ } else {
+ throw new Error(`ElevenLabs TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`);
+ }
+ }
+
+ if (!ttsResponse.ok) {
+ throw new Error(`ElevenLabs TTS error: ${ttsResponse.status}`);
+ }
+
+ const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
+ const audioSize = audioBuffer.length;
+
+ totalSize += audioSize;
+ audioChunks.push({
+ id: chunk.id,
+ start: chunk.start,
+ end: chunk.end,
+ audioBuffer,
+ size: audioSize,
+ duration: estimateAudioDuration(chunk.content, speed),
+ });
+ break; // Success, exit retry loop
+ } catch (error) {
+ retries++;
+ console.error(`Error processing ElevenLabs chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`, error);
+ if (retries >= maxRetries) {
+ throw error; // Re-throw after all retries exhausted
+ }
+ await new Promise(resolve => setTimeout(resolve, delay));
+ delay *= 2; // Exponential backoff for other errors too
+ }
+ }
+ }
+
+ return { audioChunks, totalSize };
+}
+
+// OpenAI TTS Implementation
+async function generateOpenAITTS(chunks: AudioChunk[], voice: string, speed: number) {
+ const openaiApiKey = Deno.env.get('OPENAI_API_KEY');
+ if (!openaiApiKey) {
+ throw new Error('OpenAI API key not configured');
+ }
+
+ const audioChunks = [];
+ let totalSize = 0;
+
+ for (const chunk of chunks) {
+ let retries = 0;
+ const maxRetries = 3;
+ let delay = 1000; // Start with 1 second delay
+
+ while (retries < maxRetries) {
+ try {
+ const ttsResponse = await fetch('https://api.openai.com/v1/audio/speech', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${openaiApiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ model: 'tts-1-hd', // Using HD model for better quality
+ input: chunk.content,
+ voice: voice,
+ speed: speed,
+ }),
+ });
+
+ if (ttsResponse.status === 429) {
+ retries++;
+ if (retries < maxRetries) {
+ console.log(`Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`);
+ await new Promise(resolve => setTimeout(resolve, delay));
+ delay *= 2; // Exponential backoff
+ continue;
+ } else {
+ throw new Error(`OpenAI TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`);
+ }
+ }
+
+ if (!ttsResponse.ok) {
+ throw new Error(`OpenAI TTS error: ${ttsResponse.status}`);
+ }
+
+ const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
+ const audioSize = audioBuffer.length;
+
+ totalSize += audioSize;
+ audioChunks.push({
+ id: chunk.id,
+ start: chunk.start,
+ end: chunk.end,
+ audioBuffer,
+ size: audioSize,
+ duration: estimateAudioDuration(chunk.content, speed),
+ });
+ break; // Success, exit retry loop
+ } catch (error) {
+ retries++;
+ console.error(`Error processing OpenAI chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`, error);
+ if (retries >= maxRetries) {
+ throw error; // Re-throw after all retries exhausted
+ }
+ await new Promise(resolve => setTimeout(resolve, delay));
+ delay *= 2; // Exponential backoff for other errors too
+ }
+ }
+ }
+
+ return { audioChunks, totalSize };
+}
diff --git a/apps/reader/apps/mobile/supabase/functions/get-audio-url/index.ts b/apps/reader/apps/mobile/supabase/functions/get-audio-url/index.ts
new file mode 100644
index 000000000..2512684f2
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/functions/get-audio-url/index.ts
@@ -0,0 +1,110 @@
+import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+interface AudioUrlRequest {
+ textId: string;
+ chunkId: string;
+}
+
+serve(async (req) => {
+ // Handle CORS preflight requests
+ if (req.method === 'OPTIONS') {
+ return new Response('ok', { headers: corsHeaders });
+ }
+
+ try {
+ // Initialize Supabase client
+ const supabaseClient = createClient(
+ Deno.env.get('SUPABASE_URL') ?? '',
+ Deno.env.get('SUPABASE_ANON_KEY') ?? '',
+ {
+ global: {
+ headers: { Authorization: req.headers.get('Authorization')! },
+ },
+ }
+ );
+
+ // Get user from JWT token
+ const {
+ data: { user },
+ } = await supabaseClient.auth.getUser();
+
+ if (!user) {
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ const { textId, chunkId }: AudioUrlRequest = await req.json();
+
+ // Validate input
+ if (!textId || !chunkId) {
+ return new Response(JSON.stringify({ error: 'Missing required fields' }), {
+ status: 400,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Verify text belongs to user
+ const { data: text, error: textError } = await supabaseClient
+ .from('texts')
+ .select('data')
+ .eq('id', textId)
+ .eq('user_id', user.id)
+ .single();
+
+ if (textError || !text) {
+ return new Response(JSON.stringify({ error: 'Text not found' }), {
+ status: 404,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Find the chunk
+ const chunk = text.data.audio?.chunks?.find((c: any) => c.id === chunkId);
+ if (!chunk) {
+ return new Response(JSON.stringify({ error: 'Chunk not found' }), {
+ status: 404,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+
+ // Generate signed URL for the audio file with user-specific path
+ const filePath = `${user.id}/${textId}/${chunkId}.mp3`;
+ const { data: urlData, error: urlError } = await supabaseClient.storage
+ .from('audio')
+ .createSignedUrl(filePath, 3600); // 1 hour expiration
+
+ if (urlError) {
+ throw urlError;
+ }
+
+ return new Response(
+ JSON.stringify({
+ success: true,
+ url: urlData.signedUrl,
+ chunk: {
+ id: chunk.id,
+ start: chunk.start,
+ end: chunk.end,
+ duration: chunk.duration,
+ },
+ }),
+ {
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ }
+ );
+ } catch (error) {
+ console.error('Error in get-audio-url function:', error);
+ return new Response(JSON.stringify({ error: error.message }), {
+ status: 500,
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ });
+ }
+});
diff --git a/apps/reader/apps/mobile/supabase/migrations/20240116_create_texts_table.sql b/apps/reader/apps/mobile/supabase/migrations/20240116_create_texts_table.sql
new file mode 100644
index 000000000..2fec5eaea
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/migrations/20240116_create_texts_table.sql
@@ -0,0 +1,62 @@
+-- Enable UUID extension
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+-- Die einzige Tabelle die du brauchst
+CREATE TABLE texts (
+ id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
+ user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
+
+ -- Der eigentliche Content
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+
+ -- ALLES andere in einem JSONB Feld
+ data JSONB DEFAULT '{}' NOT NULL,
+
+ -- Nur die absolut nötigen Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Indizes für Performance
+CREATE INDEX idx_texts_user ON texts(user_id);
+CREATE INDEX idx_texts_data ON texts USING GIN (data);
+
+-- RLS aktivieren
+ALTER TABLE texts ENABLE ROW LEVEL SECURITY;
+
+-- Jeder sieht nur seine eigenen Texte
+CREATE POLICY "Own texts only" ON texts
+ FOR ALL USING (auth.uid() = user_id);
+
+-- Update Timestamp Trigger
+CREATE OR REPLACE FUNCTION update_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER update_texts_updated_at
+ BEFORE UPDATE ON texts
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at();
+
+-- Hilfsfunktion für atomare Play Count Updates
+CREATE OR REPLACE FUNCTION increment_play_count(text_id UUID)
+RETURNS void AS $$
+BEGIN
+ UPDATE texts
+ SET data = jsonb_set(
+ jsonb_set(
+ data,
+ '{stats,playCount}',
+ to_jsonb(COALESCE((data->'stats'->>'playCount')::int, 0) + 1)
+ ),
+ '{tts,lastPlayed}',
+ to_jsonb(NOW())
+ )
+ WHERE id = text_id AND user_id = auth.uid();
+END;
+$$ LANGUAGE plpgsql;
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/migrations/20240117_create_audio_storage.sql b/apps/reader/apps/mobile/supabase/migrations/20240117_create_audio_storage.sql
new file mode 100644
index 000000000..c9f74174b
--- /dev/null
+++ b/apps/reader/apps/mobile/supabase/migrations/20240117_create_audio_storage.sql
@@ -0,0 +1,31 @@
+-- Create audio storage bucket
+INSERT INTO storage.buckets (id, name, public)
+VALUES ('audio', 'audio', false);
+
+-- Create policy for authenticated users to upload their own audio files
+CREATE POLICY "Users can upload their own audio files" ON storage.objects
+FOR INSERT WITH CHECK (
+ bucket_id = 'audio' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- Create policy for authenticated users to view their own audio files
+CREATE POLICY "Users can view their own audio files" ON storage.objects
+FOR SELECT USING (
+ bucket_id = 'audio' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- Create policy for authenticated users to update their own audio files
+CREATE POLICY "Users can update their own audio files" ON storage.objects
+FOR UPDATE USING (
+ bucket_id = 'audio' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
+
+-- Create policy for authenticated users to delete their own audio files
+CREATE POLICY "Users can delete their own audio files" ON storage.objects
+FOR DELETE USING (
+ bucket_id = 'audio' AND
+ auth.uid()::text = (storage.foldername(name))[1]
+);
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/tailwind.config.js b/apps/reader/apps/mobile/tailwind.config.js
new file mode 100644
index 000000000..141ccd331
--- /dev/null
+++ b/apps/reader/apps/mobile/tailwind.config.js
@@ -0,0 +1,10 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}', './hooks/**/*.{js,ts,tsx}'],
+ darkMode: 'class',
+ presets: [require('nativewind/preset')],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git a/apps/reader/apps/mobile/tsconfig.json b/apps/reader/apps/mobile/tsconfig.json
new file mode 100644
index 000000000..0d32ec188
--- /dev/null
+++ b/apps/reader/apps/mobile/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "jsx": "react-jsx",
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["*"]
+ }
+ },
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
+}
diff --git a/apps/reader/apps/mobile/types/database.ts b/apps/reader/apps/mobile/types/database.ts
new file mode 100644
index 000000000..c2106b7fb
--- /dev/null
+++ b/apps/reader/apps/mobile/types/database.ts
@@ -0,0 +1,79 @@
+export interface Text {
+ id: string;
+ user_id: string;
+ title: string;
+ content: string;
+ data: TextData;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface TextData {
+ // Vorlese-Einstellungen
+ tts?: {
+ speed: number;
+ voice: string;
+ lastPosition?: number;
+ lastPlayed?: string;
+ };
+
+ // Legacy Audio-Cache (für Abwärtskompatibilität)
+ audio?: {
+ hasLocalCache: boolean;
+ chunks: AudioChunk[];
+ totalSize: number;
+ lastGenerated?: string;
+ settings?: {
+ voice: string;
+ speed: number;
+ };
+ };
+
+ // Neue Audio-Versionen
+ audioVersions?: AudioVersion[];
+ currentAudioVersion?: string; // ID der aktiven Version
+
+ // Organisation
+ tags?: string[];
+ color?: string;
+
+ // Statistiken
+ stats?: {
+ playCount: number;
+ totalTime: number;
+ completed: boolean;
+ };
+
+ // Zusätzliche Felder
+ notes?: string;
+ source?: string;
+ bookmarks?: Bookmark[];
+}
+
+export interface AudioVersion {
+ id: string; // z.B. "v1-1736979654989"
+ chunks: AudioChunk[];
+ settings: {
+ voice: string;
+ speed: number;
+ };
+ totalSize: number;
+ hasLocalCache: boolean;
+ createdAt: string;
+}
+
+export interface AudioChunk {
+ id: string;
+ start: number;
+ end: number;
+ filename: string;
+ size: number;
+ duration: number;
+ createdAt: string;
+}
+
+export interface Bookmark {
+ position: number;
+ note?: string;
+ created: string;
+}
diff --git a/apps/reader/apps/mobile/utils/audioMigration.ts b/apps/reader/apps/mobile/utils/audioMigration.ts
new file mode 100644
index 000000000..10580b34f
--- /dev/null
+++ b/apps/reader/apps/mobile/utils/audioMigration.ts
@@ -0,0 +1,60 @@
+import { TextData, AudioVersion } from '~/types/database';
+
+/**
+ * Migriert alte Audio-Daten zum neuen audioVersions Format
+ */
+export function migrateAudioData(data: TextData): TextData {
+ // Wenn bereits audioVersions existiert, keine Migration nötig
+ if (data.audioVersions && data.audioVersions.length > 0) {
+ return data;
+ }
+
+ // Wenn alte audio Daten existieren, migriere sie
+ if (data.audio && data.audio.chunks && data.audio.chunks.length > 0) {
+ const versionId = `v1-${data.audio.lastGenerated ? new Date(data.audio.lastGenerated).getTime() : Date.now()}`;
+ const audioVersion: AudioVersion = {
+ id: versionId,
+ chunks: data.audio.chunks,
+ settings: data.audio.settings || {
+ voice: data.tts?.voice || 'de-DE-Neural2-A',
+ speed: data.tts?.speed || 1,
+ },
+ totalSize: data.audio.totalSize,
+ hasLocalCache: data.audio.hasLocalCache,
+ createdAt: data.audio.lastGenerated || new Date().toISOString(),
+ };
+
+ return {
+ ...data,
+ audioVersions: [audioVersion],
+ currentAudioVersion: versionId,
+ };
+ }
+
+ // Keine Audio-Daten vorhanden
+ return data;
+}
+
+/**
+ * Holt die aktuelle Audio-Version basierend auf currentAudioVersion
+ */
+export function getCurrentAudioVersion(data: TextData): AudioVersion | null {
+ if (!data.audioVersions || data.audioVersions.length === 0) {
+ return null;
+ }
+
+ if (data.currentAudioVersion) {
+ const version = data.audioVersions.find((v) => v.id === data.currentAudioVersion);
+ if (version) return version;
+ }
+
+ // Fallback: nimm die neueste Version
+ return data.audioVersions[data.audioVersions.length - 1];
+}
+
+/**
+ * Generiert eine neue Versions-ID
+ */
+export function generateVersionId(): string {
+ return `v${Date.now()}`;
+}
diff --git a/apps/reader/apps/mobile/utils/storage.ts b/apps/reader/apps/mobile/utils/storage.ts
new file mode 100644
index 000000000..fbe65033f
--- /dev/null
+++ b/apps/reader/apps/mobile/utils/storage.ts
@@ -0,0 +1,29 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { Platform } from 'react-native';
+
+// Platform-specific storage adapter for Supabase
+const createStorage = () => {
+ // For web/SSR environments, use a no-op storage or localStorage
+ if (Platform.OS === 'web') {
+ // Check if we're in a browser environment
+ if (typeof window !== 'undefined' && window.localStorage) {
+ return {
+ getItem: async (key: string) => window.localStorage.getItem(key),
+ setItem: async (key: string, value: string) => window.localStorage.setItem(key, value),
+ removeItem: async (key: string) => window.localStorage.removeItem(key),
+ };
+ } else {
+ // SSR environment - return no-op storage
+ return {
+ getItem: async () => null,
+ setItem: async () => {},
+ removeItem: async () => {},
+ };
+ }
+ }
+
+ // For native platforms, use AsyncStorage
+ return AsyncStorage;
+};
+
+export const storage = createStorage();
diff --git a/apps/reader/apps/mobile/utils/supabase.ts b/apps/reader/apps/mobile/utils/supabase.ts
new file mode 100644
index 000000000..c9eff9839
--- /dev/null
+++ b/apps/reader/apps/mobile/utils/supabase.ts
@@ -0,0 +1,14 @@
+import { createClient } from '@supabase/supabase-js';
+import { storage } from './storage';
+
+const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
+const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
+
+export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
+ auth: {
+ storage: storage,
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: false,
+ },
+});
diff --git a/package.json b/package.json
index 4822c1983..fc5c0cf7d 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "manacore-monorepo",
"version": "1.0.0",
"private": true,
- "description": "Manacore Monorepo containing maerchenzauber, manacore, manadeck, memoro, picture, uload, chat, nutriphi, news, wisekeep, quote, and bauntown",
+ "description": "Manacore Monorepo containing maerchenzauber, manacore, manadeck, memoro, picture, uload, chat, nutriphi, news, wisekeep, quote, bauntown, presi, and reader",
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
@@ -90,6 +90,18 @@
"bauntown:dev": "turbo run dev --filter=bauntown...",
"dev:bauntown:landing": "pnpm --filter @bauntown/landing dev",
+ "presi:dev": "turbo run dev --filter=presi...",
+ "dev:presi:mobile": "pnpm --filter @presi/mobile dev",
+ "dev:presi:web": "pnpm --filter @presi/web dev",
+ "dev:presi:backend": "pnpm --filter @presi/backend dev",
+ "dev:presi:app": "turbo run dev --filter=@presi/web --filter=@presi/backend",
+ "presi:db:push": "pnpm --filter @presi/backend db:push",
+ "presi:db:studio": "pnpm --filter @presi/backend db:studio",
+ "presi:db:seed": "pnpm --filter @presi/backend db:seed",
+
+ "reader:dev": "turbo run dev --filter=reader...",
+ "dev:reader:mobile": "pnpm --filter @reader/mobile dev",
+
"docker:up": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis",
"docker:up:auth": "docker compose -f docker-compose.dev.yml --env-file .env.development --profile auth up -d",
"docker:up:chat": "docker compose -f docker-compose.dev.yml --env-file .env.development --profile chat up -d",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0ea4ec736..9045b2704 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3160,6 +3160,58 @@ importers:
specifier: ^5.7.2
version: 5.9.3
+ apps/presi/apps/web:
+ dependencies:
+ '@presi/shared':
+ specifier: workspace:*
+ version: link:../../packages/shared
+ lucide-svelte:
+ specifier: ^0.460.0
+ version: 0.460.1(svelte@5.44.0)
+ devDependencies:
+ '@sveltejs/adapter-auto':
+ specifier: ^3.0.0
+ version: 3.3.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))
+ '@sveltejs/kit':
+ specifier: ^2.0.0
+ version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
+ '@sveltejs/vite-plugin-svelte':
+ specifier: ^5.0.0
+ version: 5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
+ '@types/node':
+ specifier: ^20.0.0
+ version: 20.19.25
+ autoprefixer:
+ specifier: ^10.4.16
+ version: 10.4.22(postcss@8.5.6)
+ postcss:
+ specifier: ^8.4.32
+ version: 8.5.6
+ prettier:
+ specifier: ^3.1.1
+ version: 3.6.2
+ prettier-plugin-svelte:
+ specifier: ^3.1.2
+ version: 3.4.0(prettier@3.6.2)(svelte@5.44.0)
+ svelte:
+ specifier: ^5.0.0
+ version: 5.44.0
+ svelte-check:
+ specifier: ^4.0.0
+ version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3)
+ tailwindcss:
+ specifier: ^3.4.0
+ version: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
+ tslib:
+ specifier: ^2.4.1
+ version: 2.8.1
+ typescript:
+ specifier: ^5.0.0
+ version: 5.9.3
+ vite:
+ specifier: ^6.0.0
+ version: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
+
apps/presi/packages/shared:
devDependencies:
typescript:
@@ -3547,6 +3599,118 @@ importers:
specifier: ^5.0.0
version: 5.9.3
+ apps/reader/apps/mobile:
+ dependencies:
+ '@expo/vector-icons':
+ specifier: ^14.0.0
+ version: 14.1.0(expo-font@14.0.9(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ '@react-native-async-storage/async-storage':
+ specifier: ^2.1.2
+ version: 2.2.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ '@react-navigation/native':
+ specifier: ^7.0.3
+ version: 7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ '@supabase/supabase-js':
+ specifier: ^2.38.4
+ version: 2.84.0
+ expo:
+ specifier: ^53.0.19
+ version: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ expo-av:
+ specifier: ^15.1.7
+ version: 15.1.7(expo@53.0.24)(react-native-web@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ expo-clipboard:
+ specifier: ^7.1.5
+ version: 7.1.5(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ expo-constants:
+ specifier: ~17.1.4
+ version: 17.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ expo-dev-client:
+ specifier: ~5.2.4
+ version: 5.2.4(expo@53.0.24)
+ expo-dev-launcher:
+ specifier: ^5.0.17
+ version: 5.1.17(expo@53.0.24)
+ expo-file-system:
+ specifier: ^18.1.11
+ version: 18.1.11(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ expo-linking:
+ specifier: ~7.1.4
+ version: 7.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ expo-router:
+ specifier: ~5.1.3
+ version: 5.1.7(6n4viumfp7necpm3lc54aw4o44)
+ expo-status-bar:
+ specifier: ~2.2.3
+ version: 2.2.3(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ expo-system-ui:
+ specifier: ~5.0.6
+ version: 5.0.11(expo@53.0.24)(react-native-web@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ expo-web-browser:
+ specifier: ~14.2.0
+ version: 14.2.0(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ nativewind:
+ specifier: latest
+ version: 4.2.1(react-native-reanimated@3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))
+ react:
+ specifier: 19.0.0
+ version: 19.0.0
+ react-dom:
+ specifier: 19.0.0
+ version: 19.0.0(react@19.0.0)
+ react-native:
+ specifier: 0.79.5
+ version: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-gesture-handler:
+ specifier: ~2.24.0
+ version: 2.24.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-reanimated:
+ specifier: ~3.17.4
+ version: 3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-safe-area-context:
+ specifier: 5.4.0
+ version: 5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-screens:
+ specifier: ~4.11.1
+ version: 4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-web:
+ specifier: ^0.20.0
+ version: 0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ zustand:
+ specifier: ^4.5.7
+ version: 4.5.7(@types/react@19.0.14)(react@19.0.0)
+ devDependencies:
+ '@babel/core':
+ specifier: ^7.20.0
+ version: 7.28.5
+ '@types/react':
+ specifier: ~19.0.10
+ version: 19.0.14
+ ajv:
+ specifier: ^8.12.0
+ version: 8.17.1
+ eslint:
+ specifier: ^9.25.1
+ version: 9.39.1(jiti@2.6.1)
+ eslint-config-expo:
+ specifier: ^9.2.0
+ version: 9.2.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3)
+ eslint-config-prettier:
+ specifier: ^10.1.2
+ version: 10.1.8(eslint@9.39.1(jiti@2.6.1))
+ prettier:
+ specifier: ^3.2.5
+ version: 3.6.2
+ prettier-plugin-tailwindcss:
+ specifier: ^0.5.11
+ version: 0.5.14(prettier-plugin-astro@0.14.1)(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.44.0))(prettier@3.6.2)
+ tailwindcss:
+ specifier: ^3.4.0
+ version: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
+ typescript:
+ specifier: ~5.8.3
+ version: 5.8.3
+
apps/uload/apps/backend:
dependencies:
'@manacore/uload-database':
@@ -4082,13 +4246,101 @@ importers:
version: 5.9.3
games/voxel-lava:
+ devDependencies:
+ turbo:
+ specifier: ^2.5.4
+ version: 2.6.1
+
+ games/voxel-lava/apps/backend:
+ dependencies:
+ '@nestjs/common':
+ specifier: ^10.4.15
+ version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/config':
+ specifier: ^3.3.0
+ version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
+ '@nestjs/core':
+ specifier: ^10.4.15
+ version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/platform-express':
+ specifier: ^10.4.15
+ version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)
+ class-transformer:
+ specifier: ^0.5.1
+ version: 0.5.1
+ class-validator:
+ specifier: ^0.14.1
+ version: 0.14.3
+ dotenv:
+ specifier: ^16.4.7
+ version: 16.6.1
+ drizzle-kit:
+ specifier: ^0.30.2
+ version: 0.30.6
+ drizzle-orm:
+ specifier: ^0.38.3
+ version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.7)(expo-sqlite@15.2.14(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(kysely@0.28.8)(postgres@3.4.7)(react@19.1.0)
+ postgres:
+ specifier: ^3.4.5
+ version: 3.4.7
+ reflect-metadata:
+ specifier: ^0.2.2
+ version: 0.2.2
+ rxjs:
+ specifier: ^7.8.1
+ version: 7.8.2
+ devDependencies:
+ '@nestjs/cli':
+ specifier: ^10.4.9
+ version: 10.4.9(esbuild@0.27.0)
+ '@nestjs/schematics':
+ specifier: ^10.2.3
+ version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
+ '@types/express':
+ specifier: ^5.0.0
+ version: 5.0.5
+ '@types/node':
+ specifier: ^22.10.2
+ version: 22.19.1
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^8.18.1
+ version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ '@typescript-eslint/parser':
+ specifier: ^8.18.1
+ version: 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
+ eslint:
+ specifier: ^9.17.0
+ version: 9.39.1(jiti@2.6.1)
+ eslint-config-prettier:
+ specifier: ^9.1.0
+ version: 9.1.2(eslint@9.39.1(jiti@2.6.1))
+ eslint-plugin-prettier:
+ specifier: ^5.2.1
+ version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)
+ prettier:
+ specifier: ^3.4.2
+ version: 3.6.2
+ source-map-support:
+ specifier: ^0.5.21
+ version: 0.5.21
+ ts-loader:
+ specifier: ^9.5.1
+ version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0))
+ ts-node:
+ specifier: ^10.9.2
+ version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
+ tsconfig-paths:
+ specifier: ^4.2.0
+ version: 4.2.0
+ tsx:
+ specifier: ^4.19.2
+ version: 4.20.6
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+
+ games/voxel-lava/apps/web:
dependencies:
- axios:
- specifier: ^1.11.0
- version: 1.13.2
- pocketbase:
- specifier: ^0.26.2
- version: 0.26.3
three:
specifier: ^0.176.0
version: 0.176.0
@@ -9057,6 +9309,10 @@ packages:
resolution: {integrity: sha512-Vy8DQXCJ21YSAiHxrNBz35VqVlZPpRYm50xRTWRf660JwHuJkFQG8cUkrLzm7AUriqUXxwpkQHcY+b0ibw9ejQ==}
engines: {node: '>=18'}
+ '@react-native/assets-registry@0.79.5':
+ resolution: {integrity: sha512-N4Kt1cKxO5zgM/BLiyzuuDNquZPiIgfktEQ6TqJ/4nKA8zr4e8KJgU6Tb2eleihDO4E24HmkvGc73naybKRz/w==}
+ engines: {node: '>=18'}
+
'@react-native/assets-registry@0.81.4':
resolution: {integrity: sha512-AMcDadefBIjD10BRqkWw+W/VdvXEomR6aEZ0fhQRAv7igrBzb4PTn4vHKYg+sUK0e3wa74kcMy2DLc/HtnGcMA==}
engines: {node: '>= 20.19.4'}
@@ -9155,6 +9411,12 @@ packages:
peerDependencies:
'@babel/core': '*'
+ '@react-native/codegen@0.79.5':
+ resolution: {integrity: sha512-FO5U1R525A1IFpJjy+KVznEinAgcs3u7IbnbRJUG9IH/MBXi2lEU2LtN+JarJ81MCfW4V2p0pg6t/3RGHFRrlQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@babel/core': '*'
+
'@react-native/codegen@0.79.6':
resolution: {integrity: sha512-iRBX8Lgbqypwnfba7s6opeUwVyaR23mowh9ILw7EcT2oLz3RqMmjJdrbVpWhGSMGq2qkPfqAH7bhO8C7O+xfjQ==}
engines: {node: '>=18'}
@@ -9209,6 +9471,15 @@ packages:
'@react-native-community/cli':
optional: true
+ '@react-native/community-cli-plugin@0.79.5':
+ resolution: {integrity: sha512-ApLO1ARS8JnQglqS3JAHk0jrvB+zNW3dvNJyXPZPoygBpZVbf8sjvqeBiaEYpn8ETbFWddebC4HoQelDndnrrA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@react-native-community/cli': '*'
+ peerDependenciesMeta:
+ '@react-native-community/cli':
+ optional: true
+
'@react-native/community-cli-plugin@0.81.4':
resolution: {integrity: sha512-8mpnvfcLcnVh+t1ok6V9eozWo8Ut+TZhz8ylJ6gF9d6q9EGDQX6s8jenan5Yv/pzN4vQEKI4ib2pTf/FELw+SA==}
engines: {node: '>= 20.19.4'}
@@ -9253,6 +9524,10 @@ packages:
resolution: {integrity: sha512-ImNDuEeKH6lEsLXms3ZsgIrNF94jymfuhPcVY5L0trzaYNo9ZFE9Ni2/18E1IbfXxdeIHrCSBJlWD6CTm7wu5A==}
engines: {node: '>=18'}
+ '@react-native/debugger-frontend@0.79.5':
+ resolution: {integrity: sha512-WQ49TRpCwhgUYo5/n+6GGykXmnumpOkl4Lr2l2o2buWU9qPOwoiBqJAtmWEXsAug4ciw3eLiVfthn5ufs0VB0A==}
+ engines: {node: '>=18'}
+
'@react-native/debugger-frontend@0.79.6':
resolution: {integrity: sha512-lIK/KkaH7ueM22bLO0YNaQwZbT/oeqhaghOvmZacaNVbJR1Cdh/XAqjT8FgCS+7PUnbxA8B55NYNKGZG3O2pYw==}
engines: {node: '>=18'}
@@ -9285,6 +9560,10 @@ packages:
resolution: {integrity: sha512-x88+RGOyG71+idQefnQg7wLhzjn/Scs+re1O5vqCkTVzRAc/f7SdHMlbmECUxJPd08FqMcOJr7/X3nsJBrNuuw==}
engines: {node: '>=18'}
+ '@react-native/dev-middleware@0.79.5':
+ resolution: {integrity: sha512-U7r9M/SEktOCP/0uS6jXMHmYjj4ESfYCkNAenBjFjjsRWekiHE+U/vRMeO+fG9gq4UCcBAUISClkQCowlftYBw==}
+ engines: {node: '>=18'}
+
'@react-native/dev-middleware@0.79.6':
resolution: {integrity: sha512-BK3GZBa9c7XSNR27EDRtxrgyyA3/mf1j3/y+mPk7Ac0Myu85YNrXnC9g3mL5Ytwo0g58TKrAIgs1fF2Q5Mn6mQ==}
engines: {node: '>=18'}
@@ -9313,6 +9592,10 @@ packages:
resolution: {integrity: sha512-imfpZLhNBc9UFSzb/MOy2tNcIBHqVmexh/qdzw83F75BmUtLb/Gs1L2V5gw+WI1r7RqDILbWk7gXB8zUllwd+g==}
engines: {node: '>=18'}
+ '@react-native/gradle-plugin@0.79.5':
+ resolution: {integrity: sha512-K3QhfFNKiWKF3HsCZCEoWwJPSMcPJQaeqOmzFP4RL8L3nkpgUwn74PfSCcKHxooVpS6bMvJFQOz7ggUZtNVT+A==}
+ engines: {node: '>=18'}
+
'@react-native/gradle-plugin@0.81.4':
resolution: {integrity: sha512-T7fPcQvDDCSusZFVSg6H1oVDKb/NnVYLnsqkcHsAF2C2KGXyo3J7slH/tJAwNfj/7EOA2OgcWxfC1frgn9TQvw==}
engines: {node: '>= 20.19.4'}
@@ -9337,6 +9620,10 @@ packages:
resolution: {integrity: sha512-PEBtg6Kox6KahjCAch0UrqCAmHiNLEbp2SblUEoFAQnov4DSxBN9safh+QSVaCiMAwLjvNfXrJyygZz60Dqz3Q==}
engines: {node: '>=18'}
+ '@react-native/js-polyfills@0.79.5':
+ resolution: {integrity: sha512-a2wsFlIhvd9ZqCD5KPRsbCQmbZi6KxhRN++jrqG0FUTEV5vY7MvjjUqDILwJd2ZBZsf7uiDuClCcKqA+EEdbvw==}
+ engines: {node: '>=18'}
+
'@react-native/js-polyfills@0.81.4':
resolution: {integrity: sha512-sr42FaypKXJHMVHhgSbu2f/ZJfrLzgaoQ+HdpRvKEiEh2mhFf6XzZwecyLBvWqf2pMPZa+CpPfNPiejXjKEy8w==}
engines: {node: '>= 20.19.4'}
@@ -9384,6 +9671,9 @@ packages:
'@react-native/normalize-colors@0.79.3':
resolution: {integrity: sha512-T75NIQPRFCj6DFMxtcVMJTZR+3vHXaUMSd15t+CkJpc5LnyX91GVaPxpRSAdjFh7m3Yppl5MpdjV/fntImheYQ==}
+ '@react-native/normalize-colors@0.79.5':
+ resolution: {integrity: sha512-nGXMNMclZgzLUxijQQ38Dm3IAEhgxuySAWQHnljFtfB0JdaMwpe0Ox9H7Tp2OgrEA+EMEv+Od9ElKlHwGKmmvQ==}
+
'@react-native/normalize-colors@0.79.6':
resolution: {integrity: sha512-0v2/ruY7eeKun4BeKu+GcfO+SHBdl0LJn4ZFzTzjHdWES0Cn+ONqKljYaIv8p9MV2Hx/kcdEvbY4lWI34jC/mQ==}
@@ -9437,6 +9727,17 @@ packages:
'@types/react':
optional: true
+ '@react-native/virtualized-lists@0.79.5':
+ resolution: {integrity: sha512-EUPM2rfGNO4cbI3olAbhPkIt3q7MapwCwAJBzUfWlZ/pu0PRNOnMQ1IvaXTf3TpeozXV52K1OdprLEI/kI5eUA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/react': ^19.0.0
+ react: '*'
+ react-native: '*'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@react-native/virtualized-lists@0.81.4':
resolution: {integrity: sha512-hBM+rMyL6Wm1Q4f/WpqGsaCojKSNUBqAXLABNGoWm1vabZ7cSnARMxBvA/2vo3hLcoR4v7zDK8tkKm9+O0LjVA==}
engines: {node: '>= 20.19.4'}
@@ -14115,6 +14416,17 @@ packages:
react: '*'
react-native: '*'
+ expo-av@15.1.7:
+ resolution: {integrity: sha512-NC+JR+65sxXfQN1mOHp3QBaXTL2J+BzNwVO27XgUEc5s9NaoBTdHWElYXrfxvik6xwytZ+a7abrqfNNgsbQzsA==}
+ peerDependencies:
+ expo: '*'
+ react: '*'
+ react-native: '*'
+ react-native-web: '*'
+ peerDependenciesMeta:
+ react-native-web:
+ optional: true
+
expo-blur@14.0.3:
resolution: {integrity: sha512-BL3xnqBJbYm3Hg9t/HjNjdeY7N/q8eK5tsLYxswWG1yElISWZmMvrXYekl7XaVCPfyFyz8vQeaxd7q74ZY3Wrw==}
peerDependencies:
@@ -14159,6 +14471,13 @@ packages:
react: '*'
react-native: '*'
+ expo-clipboard@7.1.5:
+ resolution: {integrity: sha512-TCANUGOxouoJXxKBW5ASJl2WlmQLGpuZGemDCL2fO5ZMl57DGTypUmagb0CVUFxDl0yAtFIcESd78UsF9o64aw==}
+ peerDependencies:
+ expo: '*'
+ react: '*'
+ react-native: '*'
+
expo-clipboard@8.0.7:
resolution: {integrity: sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==}
peerDependencies:
@@ -14718,6 +15037,12 @@ packages:
expo: '*'
react-native: '*'
+ expo-web-browser@14.2.0:
+ resolution: {integrity: sha512-6S51d8pVlDRDsgGAp8BPpwnxtyKiMWEFdezNz+5jVIyT+ctReW42uxnjRgtsdn5sXaqzhaX+Tzk/CWaKCyC0hw==}
+ peerDependencies:
+ expo: '*'
+ react-native: '*'
+
expo-web-browser@15.0.9:
resolution: {integrity: sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg==}
peerDependencies:
@@ -16996,6 +17321,11 @@ packages:
lru-queue@0.1.0:
resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==}
+ lucide-svelte@0.460.1:
+ resolution: {integrity: sha512-guJjJSeWlKivsEG51Xg41egunBMJcobhDmkLv/NYTXmXyCEwxMf1A+HX7RvDEKiXbXYgtLImih67tI08MSUOkA==}
+ peerDependencies:
+ svelte: ^3 || ^4 || ^5.0.0-next.42
+
lucide-svelte@0.539.0:
resolution: {integrity: sha512-p4k3GOje/9Si1eIkg1W1OQUhozeja5Ka5shjVpfyP5X2ye+B7sfyMnX3d5D2et+MYJwUFGrMna5MIYgq6bLfqw==}
peerDependencies:
@@ -19355,6 +19685,17 @@ packages:
'@types/react':
optional: true
+ react-native@0.79.5:
+ resolution: {integrity: sha512-jVihwsE4mWEHZ9HkO1J2eUZSwHyDByZOqthwnGrVZCh6kTQBCm4v8dicsyDa6p0fpWNE5KicTcpX/XXl0ASJFg==}
+ engines: {node: '>=18'}
+ hasBin: true
+ peerDependencies:
+ '@types/react': ^19.0.0
+ react: ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
react-native@0.81.4:
resolution: {integrity: sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==}
engines: {node: '>= 20.19.4'}
@@ -25688,6 +26029,10 @@ snapshots:
dependencies:
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ '@expo/metro-runtime@5.0.5(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))':
+ dependencies:
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
'@expo/metro-runtime@6.1.2(expo@52.0.47)(react-dom@18.3.1(react@18.3.1))(react-native@0.76.3(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)':
dependencies:
anser: 1.4.10
@@ -25740,6 +26085,19 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
optional: true
+ '@expo/metro-runtime@6.1.2(expo@53.0.24)(react-dom@19.0.0(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ anser: 1.4.10
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ pretty-format: 29.7.0
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ stacktrace-parser: 0.1.11
+ whatwg-fetch: 3.6.20
+ optionalDependencies:
+ react-dom: 19.0.0(react@19.0.0)
+ optional: true
+
'@expo/metro-runtime@6.1.2(expo@54.0.12)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
dependencies:
anser: 1.4.10
@@ -26031,6 +26389,12 @@ snapshots:
react: 19.0.0
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ '@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.24)(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ expo-font: 13.3.2(expo@53.0.24)(react@19.0.0)
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
'@expo/vector-icons@14.1.0(expo-font@14.0.9(expo@52.0.47)(react-native@0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)':
dependencies:
expo-font: 14.0.9(expo@52.0.47)(react-native@0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)
@@ -26043,6 +26407,12 @@ snapshots:
react: 18.3.1
react-native: 0.76.7(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1)
+ '@expo/vector-icons@14.1.0(expo-font@14.0.9(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ expo-font: 14.0.9(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
'@expo/vector-icons@15.0.3(expo-font@14.0.9(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
dependencies:
expo-font: 14.0.9(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@@ -28772,6 +29142,11 @@ snapshots:
merge-options: 3.0.4
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ '@react-native-async-storage/async-storage@2.2.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))':
+ dependencies:
+ merge-options: 3.0.4
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
'@react-native-async-storage/async-storage@2.2.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))':
dependencies:
merge-options: 3.0.4
@@ -28850,6 +29225,8 @@ snapshots:
'@react-native/assets-registry@0.79.3': {}
+ '@react-native/assets-registry@0.79.5': {}
+
'@react-native/assets-registry@0.81.4': {}
'@react-native/assets-registry@0.81.5': {}
@@ -29267,6 +29644,15 @@ snapshots:
nullthrows: 1.1.1
yargs: 17.7.2
+ '@react-native/codegen@0.79.5(@babel/core@7.28.5)':
+ dependencies:
+ '@babel/core': 7.28.5
+ glob: 7.2.3
+ hermes-parser: 0.25.1
+ invariant: 2.2.4
+ nullthrows: 1.1.1
+ yargs: 17.7.2
+
'@react-native/codegen@0.79.6(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
@@ -29374,6 +29760,21 @@ snapshots:
- supports-color
- utf-8-validate
+ '@react-native/community-cli-plugin@0.79.5':
+ dependencies:
+ '@react-native/dev-middleware': 0.79.5
+ chalk: 4.1.2
+ debug: 2.6.9
+ invariant: 2.2.4
+ metro: 0.82.5
+ metro-config: 0.82.5
+ metro-core: 0.82.5
+ semver: 7.7.3
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
'@react-native/community-cli-plugin@0.81.4':
dependencies:
'@react-native/dev-middleware': 0.81.4
@@ -29412,6 +29813,8 @@ snapshots:
'@react-native/debugger-frontend@0.79.3': {}
+ '@react-native/debugger-frontend@0.79.5': {}
+
'@react-native/debugger-frontend@0.79.6': {}
'@react-native/debugger-frontend@0.81.4': {}
@@ -29510,6 +29913,24 @@ snapshots:
- supports-color
- utf-8-validate
+ '@react-native/dev-middleware@0.79.5':
+ dependencies:
+ '@isaacs/ttlcache': 1.4.1
+ '@react-native/debugger-frontend': 0.79.5
+ chrome-launcher: 0.15.2
+ chromium-edge-launcher: 0.2.0
+ connect: 3.7.0
+ debug: 2.6.9
+ invariant: 2.2.4
+ nullthrows: 1.1.1
+ open: 7.4.2
+ serve-static: 1.16.2
+ ws: 6.2.3
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
'@react-native/dev-middleware@0.79.6':
dependencies:
'@isaacs/ttlcache': 1.4.1
@@ -29572,6 +29993,8 @@ snapshots:
'@react-native/gradle-plugin@0.79.3': {}
+ '@react-native/gradle-plugin@0.79.5': {}
+
'@react-native/gradle-plugin@0.81.4': {}
'@react-native/gradle-plugin@0.81.5': {}
@@ -29584,6 +30007,8 @@ snapshots:
'@react-native/js-polyfills@0.79.3': {}
+ '@react-native/js-polyfills@0.79.5': {}
+
'@react-native/js-polyfills@0.81.4': {}
'@react-native/js-polyfills@0.81.5': {}
@@ -29632,6 +30057,8 @@ snapshots:
'@react-native/normalize-colors@0.79.3': {}
+ '@react-native/normalize-colors@0.79.5': {}
+
'@react-native/normalize-colors@0.79.6': {}
'@react-native/normalize-colors@0.81.4': {}
@@ -29674,6 +30101,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.14
+ '@react-native/virtualized-lists@0.79.5(@types/react@19.0.14)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ invariant: 2.2.4
+ nullthrows: 1.1.1
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ optionalDependencies:
+ '@types/react': 19.0.14
+
'@react-native/virtualized-lists@0.81.4(@types/react@19.2.7)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
dependencies:
invariant: 2.2.4
@@ -29753,6 +30189,19 @@ snapshots:
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
+ '@react-navigation/bottom-tabs@7.8.6(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ '@react-navigation/native': 7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ color: 4.2.3
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-safe-area-context: 5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-screens: 4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ sf-symbols-typescript: 2.1.0
+ transitivePeerDependencies:
+ - '@react-native-masked-view/masked-view'
+
'@react-navigation/bottom-tabs@7.8.6(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
dependencies:
'@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@@ -29832,6 +30281,23 @@ snapshots:
- '@react-native-masked-view/masked-view'
optional: true
+ '@react-navigation/drawer@7.7.4(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-gesture-handler@2.24.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-reanimated@3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ '@react-navigation/native': 7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ color: 4.2.3
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-drawer-layout: 4.2.0(react-native-gesture-handler@2.24.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-reanimated@3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-gesture-handler: 2.24.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-reanimated: 3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-safe-area-context: 5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-screens: 4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ use-latest-callback: 0.2.6(react@19.0.0)
+ transitivePeerDependencies:
+ - '@react-native-masked-view/masked-view'
+ optional: true
+
'@react-navigation/drawer@7.7.4(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.0(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
dependencies:
'@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@@ -29989,6 +30455,16 @@ snapshots:
use-latest-callback: 0.2.6(react@19.0.0)
use-sync-external-store: 1.6.0(react@19.0.0)
+ '@react-navigation/elements@2.8.3(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@react-navigation/native': 7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ color: 4.2.3
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-safe-area-context: 5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ use-latest-callback: 0.2.6(react@19.0.0)
+ use-sync-external-store: 1.6.0(react@19.0.0)
+
'@react-navigation/elements@2.8.3(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
dependencies:
'@react-navigation/native': 7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@@ -30065,6 +30541,20 @@ snapshots:
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
+ '@react-navigation/native-stack@7.8.0(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ '@react-navigation/native': 7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ color: 4.2.3
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-safe-area-context: 5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-screens: 4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ sf-symbols-typescript: 2.1.0
+ warn-once: 0.1.1
+ transitivePeerDependencies:
+ - '@react-native-masked-view/masked-view'
+
'@react-navigation/native-stack@7.8.0(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
dependencies:
'@react-navigation/elements': 2.8.3(@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@@ -30133,6 +30623,16 @@ snapshots:
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
use-latest-callback: 0.2.6(react@19.0.0)
+ '@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
+ dependencies:
+ '@react-navigation/core': 7.13.2(react@19.0.0)
+ escape-string-regexp: 4.0.0
+ fast-deep-equal: 3.1.3
+ nanoid: 3.3.11
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ use-latest-callback: 0.2.6(react@19.0.0)
+
'@react-navigation/native@7.1.21(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
dependencies:
'@react-navigation/core': 7.13.2(react@19.1.0)
@@ -36923,6 +37423,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ expo-asset@11.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ '@expo/image-utils': 0.7.6
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ expo-constants: 17.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ transitivePeerDependencies:
+ - supports-color
+
expo-asset@12.0.10(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
'@expo/image-utils': 0.8.7
@@ -36970,6 +37480,14 @@ snapshots:
react: 19.1.0
react-native: 0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)
+ expo-av@15.1.7(expo@53.0.24)(react-native-web@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ optionalDependencies:
+ react-native-web: 0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+
expo-blur@14.0.3(expo@52.0.47)(react-native@0.76.3(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1):
dependencies:
expo: 52.0.47(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.76.3(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.76.3(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)
@@ -37033,6 +37551,12 @@ snapshots:
react: 18.3.1
react-native: 0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1)
+ expo-clipboard@7.1.5(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
expo-clipboard@8.0.7(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
expo: 54.0.12(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@@ -37087,6 +37611,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ expo-constants@17.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)):
+ dependencies:
+ '@expo/config': 11.0.13
+ '@expo/env': 1.0.7
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ transitivePeerDependencies:
+ - supports-color
+
expo-constants@18.0.10(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)):
dependencies:
'@expo/config': 12.0.10
@@ -37336,6 +37869,11 @@ snapshots:
expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ expo-file-system@18.1.11(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)):
+ dependencies:
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
expo-file-system@19.0.19(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)):
dependencies:
expo: 54.0.12(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@@ -37382,6 +37920,13 @@ snapshots:
react: 18.3.1
react-native: 0.76.7(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1)
+ expo-font@14.0.9(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ fontfaceobserver: 2.3.0
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
expo-font@14.0.9(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
expo: 54.0.12(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@@ -37561,6 +38106,16 @@ snapshots:
- expo
- supports-color
+ expo-linking@7.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ expo-constants: 17.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ invariant: 2.2.4
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ transitivePeerDependencies:
+ - expo
+ - supports-color
+
expo-linking@8.0.9(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
expo-constants: 18.0.10(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))
@@ -37846,6 +38401,37 @@ snapshots:
- react-native
- supports-color
+ expo-router@5.1.7(6n4viumfp7necpm3lc54aw4o44):
+ dependencies:
+ '@expo/metro-runtime': 5.0.5(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ '@expo/schema-utils': 0.1.7
+ '@expo/server': 0.6.3
+ '@radix-ui/react-slot': 1.2.0(@types/react@19.0.14)(react@19.0.0)
+ '@react-navigation/bottom-tabs': 7.8.6(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ '@react-navigation/native': 7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ '@react-navigation/native-stack': 7.8.0(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ client-only: 0.0.1
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ expo-constants: 17.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ expo-linking: 7.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ invariant: 2.2.4
+ react-fast-compare: 3.2.2
+ react-native-is-edge-to-edge: 1.2.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-safe-area-context: 5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-screens: 4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ semver: 7.6.3
+ server-only: 0.0.1
+ shallowequal: 1.1.0
+ optionalDependencies:
+ '@react-navigation/drawer': 7.7.4(@react-navigation/native@7.1.21(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-gesture-handler@2.24.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-reanimated@3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-reanimated: 3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ transitivePeerDependencies:
+ - '@react-native-masked-view/masked-view'
+ - '@types/react'
+ - react
+ - react-native
+ - supports-color
+
expo-router@5.1.7(duagq234s5dj4m7fdjuubauzmi):
dependencies:
'@expo/metro-runtime': 5.0.5(react-native@0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
@@ -38286,6 +38872,13 @@ snapshots:
react-native-edge-to-edge: 1.6.0(react-native@0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
react-native-is-edge-to-edge: 1.2.1(react-native@0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ expo-status-bar@2.2.3(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-edge-to-edge: 1.6.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-is-edge-to-edge: 1.2.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+
expo-status-bar@3.0.8(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
@@ -38373,6 +38966,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ expo-system-ui@5.0.11(expo@53.0.24)(react-native-web@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)):
+ dependencies:
+ '@react-native/normalize-colors': 0.79.6
+ debug: 4.4.3
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ optionalDependencies:
+ react-native-web: 0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ transitivePeerDependencies:
+ - supports-color
+
expo-system-ui@6.0.8(expo@54.0.12)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)):
dependencies:
'@react-native/normalize-colors': 0.81.5
@@ -38470,6 +39074,11 @@ snapshots:
expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ expo-web-browser@14.2.0(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)):
+ dependencies:
+ expo: 53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
expo-web-browser@15.0.9(expo@54.0.12)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0)):
dependencies:
expo: 54.0.12(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)
@@ -38630,6 +39239,38 @@ snapshots:
- supports-color
- utf-8-validate
+ expo@53.0.24(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ '@babel/runtime': 7.28.4
+ '@expo/cli': 0.24.22
+ '@expo/config': 11.0.13
+ '@expo/config-plugins': 10.1.2
+ '@expo/fingerprint': 0.13.4
+ '@expo/metro-config': 0.20.17
+ '@expo/vector-icons': 14.1.0(expo-font@13.3.2(expo@53.0.24)(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ babel-preset-expo: 13.2.4(@babel/core@7.28.5)
+ expo-asset: 11.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ expo-constants: 17.1.7(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ expo-file-system: 18.1.11(expo@53.0.24)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))
+ expo-font: 13.3.2(expo@53.0.24)(react@19.0.0)
+ expo-keep-awake: 14.1.4(expo@53.0.24)(react@19.0.0)
+ expo-modules-autolinking: 2.1.14
+ expo-modules-core: 2.5.0
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-edge-to-edge: 1.6.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ whatwg-url-without-unicode: 8.0.0-3
+ optionalDependencies:
+ '@expo/metro-runtime': 6.1.2(expo@53.0.24)(react-dom@19.0.0(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-webview: 13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - babel-plugin-react-compiler
+ - bufferutil
+ - graphql
+ - supports-color
+ - utf-8-validate
+
expo@54.0.12(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.28.4
@@ -42217,6 +42858,10 @@ snapshots:
dependencies:
es5-ext: 0.10.64
+ lucide-svelte@0.460.1(svelte@5.44.0):
+ dependencies:
+ svelte: 5.44.0
+
lucide-svelte@0.539.0(svelte@5.44.0):
dependencies:
svelte: 5.44.0
@@ -43867,6 +44512,20 @@ snapshots:
- react-native-svg
- supports-color
+ nativewind@4.2.1(react-native-reanimated@3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)):
+ dependencies:
+ comment-json: 4.4.1
+ debug: 4.4.3
+ react-native-css-interop: 0.2.1(react-native-reanimated@3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1))
+ tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
+ transitivePeerDependencies:
+ - react
+ - react-native
+ - react-native-reanimated
+ - react-native-safe-area-context
+ - react-native-svg
+ - supports-color
+
nativewind@4.2.1(react-native-reanimated@4.1.0(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
comment-json: 4.4.1
@@ -45122,6 +45781,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ react-native-css-interop@0.2.1(react-native-reanimated@3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)):
+ dependencies:
+ '@babel/helper-module-imports': 7.27.1
+ '@babel/traverse': 7.28.5
+ '@babel/types': 7.28.5
+ debug: 4.4.3
+ lightningcss: 1.27.0
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-reanimated: 3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ semver: 7.7.3
+ tailwindcss: 3.4.18(tsx@4.20.6)(yaml@2.8.1)
+ optionalDependencies:
+ react-native-safe-area-context: 5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ transitivePeerDependencies:
+ - supports-color
+
react-native-css-interop@0.2.1(react-native-reanimated@4.1.0(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
'@babel/helper-module-imports': 7.27.1
@@ -45257,6 +45933,16 @@ snapshots:
use-latest-callback: 0.2.6(react@19.0.0)
optional: true
+ react-native-drawer-layout@4.2.0(react-native-gesture-handler@2.24.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-reanimated@3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ color: 4.2.3
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-gesture-handler: 2.24.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ react-native-reanimated: 3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ use-latest-callback: 0.2.6(react@19.0.0)
+ optional: true
+
react-native-drawer-layout@4.2.0(react-native-gesture-handler@2.28.0(react-native@0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.6.1(@babel/core@7.28.5)(react-native@0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1):
dependencies:
color: 4.2.3
@@ -45311,6 +45997,11 @@ snapshots:
react: 19.0.0
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-edge-to-edge@1.6.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
react-native-fit-image@1.5.5:
dependencies:
prop-types: 15.8.1
@@ -45341,6 +46032,14 @@ snapshots:
react: 19.0.0
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-gesture-handler@2.24.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ '@egjs/hammerjs': 2.0.17
+ hoist-non-react-statics: 3.3.2
+ invariant: 2.2.4
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
react-native-gesture-handler@2.28.0(react-native@0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1):
dependencies:
'@egjs/hammerjs': 2.0.17
@@ -45396,6 +46095,11 @@ snapshots:
react: 19.0.0
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-is-edge-to-edge@1.1.7(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
react-native-is-edge-to-edge@1.2.1(react-native@0.76.1(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(@types/react@18.3.27)(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
@@ -45416,6 +46120,11 @@ snapshots:
react: 19.0.0
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-is-edge-to-edge@1.2.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
react-native-is-edge-to-edge@1.2.1(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
@@ -45548,6 +46257,26 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ react-native-reanimated@3.17.5(@babel/core@7.28.5)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ '@babel/core': 7.28.5
+ '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-classes': 7.28.4(@babel/core@7.28.5)
+ '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.28.5)
+ '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.28.5)
+ '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.28.5)
+ '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5)
+ convert-source-map: 2.0.0
+ invariant: 2.2.4
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-is-edge-to-edge: 1.1.7(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ transitivePeerDependencies:
+ - supports-color
+
react-native-reanimated@4.1.0(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/core': 7.28.5
@@ -45627,6 +46356,11 @@ snapshots:
react: 19.0.0
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-safe-area-context@5.4.0(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+
react-native-safe-area-context@5.6.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
@@ -45664,6 +46398,14 @@ snapshots:
react-native-is-edge-to-edge: 1.2.1(react-native@0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
warn-once: 0.1.1
+ react-native-screens@4.11.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ react: 19.0.0
+ react-freeze: 1.0.4(react@19.0.0)
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ react-native-is-edge-to-edge: 1.2.1(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ warn-once: 0.1.1
+
react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
@@ -45814,6 +46556,14 @@ snapshots:
react-native: 0.79.3(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
optional: true
+ react-native-webview@13.12.2(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
+ dependencies:
+ escape-string-regexp: 4.0.0
+ invariant: 2.2.4
+ react: 19.0.0
+ react-native: 0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0)
+ optional: true
+
react-native-webview@13.12.2(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0):
dependencies:
escape-string-regexp: 4.0.0
@@ -46149,6 +46899,54 @@ snapshots:
- supports-color
- utf-8-validate
+ react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0):
+ dependencies:
+ '@jest/create-cache-key-function': 29.7.0
+ '@react-native/assets-registry': 0.79.5
+ '@react-native/codegen': 0.79.5(@babel/core@7.28.5)
+ '@react-native/community-cli-plugin': 0.79.5
+ '@react-native/gradle-plugin': 0.79.5
+ '@react-native/js-polyfills': 0.79.5
+ '@react-native/normalize-colors': 0.79.5
+ '@react-native/virtualized-lists': 0.79.5(@types/react@19.0.14)(react-native@0.79.5(@babel/core@7.28.5)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
+ abort-controller: 3.0.0
+ anser: 1.4.10
+ ansi-regex: 5.0.1
+ babel-jest: 29.7.0(@babel/core@7.28.5)
+ babel-plugin-syntax-hermes-parser: 0.25.1
+ base64-js: 1.5.1
+ chalk: 4.1.2
+ commander: 12.1.0
+ event-target-shim: 5.0.1
+ flow-enums-runtime: 0.0.6
+ glob: 7.2.3
+ invariant: 2.2.4
+ jest-environment-node: 29.7.0
+ memoize-one: 5.2.1
+ metro-runtime: 0.82.5
+ metro-source-map: 0.82.5
+ nullthrows: 1.1.1
+ pretty-format: 29.7.0
+ promise: 8.3.0
+ react: 19.0.0
+ react-devtools-core: 6.1.5
+ react-refresh: 0.14.2
+ regenerator-runtime: 0.13.11
+ scheduler: 0.25.0
+ semver: 7.7.3
+ stacktrace-parser: 0.1.11
+ whatwg-fetch: 3.6.20
+ ws: 6.2.3
+ yargs: 17.7.2
+ optionalDependencies:
+ '@types/react': 19.0.14
+ transitivePeerDependencies:
+ - '@babel/core'
+ - '@react-native-community/cli'
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+
react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0):
dependencies:
'@jest/create-cache-key-function': 29.7.0