feat(reader): integrate reader app into monorepo structure

- Restructure to apps/reader/apps/mobile/ pattern
- Rename package to @reader/mobile
- Add reader:dev and dev:reader:mobile scripts
- Update CLAUDE.md with monorepo commands
- Remove standalone .git repository
- Convert from npm to pnpm

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-27 15:31:38 +01:00
parent 5b1e12e5d6
commit 58a342b407
82 changed files with 9831 additions and 7 deletions

40
apps/reader/.gitignore vendored Normal file
View file

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

141
apps/reader/CLAUDE.md Normal file
View file

@ -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
<View className="flex-1 items-center justify-center">
<Text className="text-lg font-bold">Hello</Text>
</View>
```
### State Management (Zustand)
```tsx
import { useStore } from '~/store/store';
const { state, actions } = useStore();
```
### Supabase Client
```tsx
// Client configured in apps/mobile/utils/supabase.ts
import { supabase } from '~/utils/supabase';
```
### Path Alias
Use `~/*` for absolute imports from mobile root:
```tsx
import { Button } from '~/components/Button';
```
## 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

View file

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

View file

@ -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
<ActionMenu
options={[
{ title: 'Open', systemIcon: 'doc.text' },
{ title: 'Share', systemIcon: 'square.and.arrow.up' },
{ title: 'Delete', systemIcon: 'trash', destructive: true },
]}
onSelect={(index) => {
// Handle selection
}}>
<YourComponent />
</ActionMenu>
```
## 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.

View file

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

View file

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

View file

@ -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 (
<View>
<Text>{text.title}</Text>
{!hasCache && (
<Button
title="Audio generieren & speichern"
onPress={handleGenerateAudio}
disabled={generating}
/>
)}
{generating && <ActivityIndicator />}
{hasCache && (
<>
<Text>
Audio gespeichert: {(text.data.audio.totalSize / 1024 / 1024).toFixed(2)} MB
</Text>
<Button title="Offline abspielen" onPress={handlePlay} />
<Button title="Cache löschen" onPress={() => clearAudioCache(text.id)} />
</>
)}
</View>
);
};
```
## 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.

View file

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

2
apps/reader/apps/mobile/app-env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
// @ts-ignore
/// <reference types="nativewind/types" />

View file

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

View file

@ -0,0 +1,15 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#fff' },
}}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
<Stack.Screen name="forgot-password" />
</Stack>
);
}

View file

@ -0,0 +1,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<string | null>(null);
const [success, setSuccess] = useState(false);
const { resetPassword } = useAuth();
const handleResetPassword = async () => {
if (!email) {
setError('Bitte gib deine E-Mail-Adresse ein');
return;
}
setLoading(true);
setError(null);
const { error } = await resetPassword(email);
if (error) {
setError(error);
setLoading(false);
} else {
setSuccess(true);
setLoading(false);
}
};
if (success) {
return (
<View className="flex-1 justify-center bg-white px-8">
<View className="text-center">
<Text className="mb-4 text-2xl font-bold text-gray-900">E-Mail gesendet!</Text>
<Text className="mb-8 text-gray-600">
Wir haben dir einen Link zum Zurücksetzen deines Passworts gesendet. Überprüfe deine
E-Mails und folge den Anweisungen.
</Text>
<Link href="/(auth)/login" asChild>
<Pressable className="rounded-lg bg-blue-600 px-4 py-3 active:bg-blue-700">
<Text className="text-center font-semibold text-white">Zurück zum Login</Text>
</Pressable>
</Link>
</View>
</View>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1 bg-white">
<View className="flex-1 justify-center px-8">
<View className="mb-8">
<Text className="mb-2 text-4xl font-bold text-gray-900">Passwort zurücksetzen</Text>
<Text className="text-gray-600">
Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen
</Text>
</View>
{error && (
<View className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3">
<Text className="text-red-700">{error}</Text>
</View>
)}
<View className="space-y-4">
<View>
<Text className="mb-1 text-sm font-medium text-gray-700">E-Mail</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="deine@email.de"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
className="rounded-lg border border-gray-300 px-4 py-3 text-base"
/>
</View>
<Pressable
onPress={handleResetPassword}
disabled={loading}
className={`rounded-lg px-4 py-3 ${
loading ? 'bg-gray-400' : 'bg-blue-600 active:bg-blue-700'
}`}>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-center text-base font-semibold text-white">
Reset-Link senden
</Text>
)}
</Pressable>
<View className="mt-4 flex-row justify-center">
<Text className="text-gray-600">Erinnerst du dich wieder? </Text>
<Link href="/(auth)/login" asChild>
<Pressable>
<Text className="font-medium text-blue-600">Anmelden</Text>
</Pressable>
</Link>
</View>
</View>
</View>
</KeyboardAvoidingView>
);
}

View file

@ -0,0 +1,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<string | null>(null);
const { signIn } = useAuth();
const { colors } = useTheme();
const handleLogin = async () => {
if (!email || !password) {
setError('Bitte fülle alle Felder aus');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('Bitte gib eine gültige E-Mail-Adresse ein');
return;
}
setLoading(true);
setError(null);
const { error } = await signIn(email, password);
if (error) {
setError(error);
setLoading(false);
} else {
router.replace('/(tabs)');
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className={`flex-1 ${colors.surface}`}>
<View className="flex-1 justify-center px-8">
<View className="mb-8">
<Text className={`mb-2 text-4xl font-bold ${colors.text}`}>Willkommen zurück</Text>
<Text className={`${colors.textSecondary}`}>Melde dich an, um fortzufahren</Text>
</View>
{error && (
<View className={`mb-4 rounded-lg border border-red-200 ${colors.errorLight} p-3`}>
<Text className="text-red-700">{error}</Text>
</View>
)}
<View className="space-y-4">
<View className="mb-4">
<Text className={`mb-1 text-sm font-medium ${colors.textSecondary}`}>E-Mail</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="deine@email.de"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
textContentType="emailAddress"
autoComplete="email"
accessibilityLabel="E-Mail eingeben"
className={`rounded-lg border ${colors.borderSecondary} px-4 py-3 text-base focus:border-blue-500 ${colors.text}`}
/>
</View>
<View className="mb-4">
<Text className={`mb-1 text-sm font-medium ${colors.textSecondary}`}>Passwort</Text>
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Dein Passwort"
secureTextEntry
textContentType="none"
autoComplete="off"
accessibilityLabel="Passwort eingeben"
className={`rounded-lg border ${colors.borderSecondary} px-4 py-3 text-base focus:border-blue-500 ${colors.text}`}
/>
</View>
<Pressable
onPress={handleLogin}
disabled={loading}
accessibilityRole="button"
accessibilityLabel="Anmelden"
className={`mt-2 rounded-lg px-4 py-3 ${loading ? 'bg-gray-400' : colors.primary}`}>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-center text-base font-semibold text-white">Anmelden</Text>
)}
</Pressable>
<View className="mt-4 flex-row justify-center">
<Text className={`${colors.textSecondary}`}>Noch kein Konto? </Text>
<Link href="/(auth)/register" asChild>
<Pressable>
<Text className="font-medium text-blue-600">Registrieren</Text>
</Pressable>
</Link>
</View>
<Link href="/(auth)/forgot-password" asChild>
<Pressable className="mt-2">
<Text className={`text-center ${colors.textSecondary}`}>Passwort vergessen?</Text>
</Pressable>
</Link>
</View>
</View>
</KeyboardAvoidingView>
);
}

View file

@ -0,0 +1,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<string | null>(null);
const { signUp } = useAuth();
const handleRegister = async () => {
if (!email || !password || !confirmPassword) {
setError('Bitte fülle alle Felder aus');
return;
}
if (password !== confirmPassword) {
setError('Passwörter stimmen nicht überein');
return;
}
if (password.length < 6) {
setError('Passwort muss mindestens 6 Zeichen lang sein');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('Bitte gib eine gültige E-Mail-Adresse ein');
return;
}
setLoading(true);
setError(null);
const { error } = await signUp(email, password);
if (error) {
setError(error);
setLoading(false);
} else {
router.replace('/(tabs)');
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1 bg-white">
<View className="flex-1 justify-center px-8">
<View className="mb-8">
<Text className="mb-2 text-4xl font-bold text-gray-900">Konto erstellen</Text>
<Text className="text-gray-600">Registriere dich für Reader</Text>
</View>
{error && (
<View className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3">
<Text className="text-red-700">{error}</Text>
</View>
)}
<View className="space-y-4">
<View className="mb-4">
<Text className="mb-1 text-sm font-medium text-gray-700">E-Mail</Text>
<TextInput
value={email}
onChangeText={setEmail}
placeholder="deine@email.de"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
textContentType="emailAddress"
autoComplete="email"
accessibilityLabel="E-Mail eingeben"
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-blue-500"
/>
</View>
<View className="mb-4">
<Text className="mb-1 text-sm font-medium text-gray-700">Passwort</Text>
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Mindestens 6 Zeichen"
secureTextEntry
textContentType="none"
autoComplete="off"
accessibilityLabel="Passwort eingeben"
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-blue-500"
/>
</View>
<View className="mb-4">
<Text className="mb-1 text-sm font-medium text-gray-700">Passwort bestätigen</Text>
<TextInput
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="Passwort wiederholen"
secureTextEntry
textContentType="none"
autoComplete="off"
accessibilityLabel="Passwort bestätigen"
className="rounded-lg border border-gray-300 px-4 py-3 text-base focus:border-blue-500"
/>
</View>
<Pressable
onPress={handleRegister}
disabled={loading}
accessibilityRole="button"
accessibilityLabel="Registrieren"
className={`mt-2 rounded-lg px-4 py-3 ${
loading ? 'bg-gray-400' : 'bg-blue-600 active:bg-blue-700'
}`}>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-center text-base font-semibold text-white">Registrieren</Text>
)}
</Pressable>
<View className="mt-4 flex-row justify-center">
<Text className="text-gray-600">Schon ein Konto? </Text>
<Link href="/(auth)/login" asChild>
<Pressable>
<Text className="font-medium text-blue-600">Anmelden</Text>
</Pressable>
</Link>
</View>
</View>
</View>
</KeyboardAvoidingView>
);
}

View file

@ -0,0 +1,34 @@
import { Tabs } from 'expo-router';
import { TabBarIcon } from '../../components/TabBarIcon';
import { useTheme } from '~/hooks/useTheme';
export default function TabLayout() {
const { colors } = useTheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.tabBarActive,
tabBarInactiveTintColor: colors.tabBarInactive,
tabBarStyle: {
backgroundColor: colors.tabBarBackground,
borderTopColor: colors.tabBarBorder,
},
}}>
<Tabs.Screen
name="index"
options={{
title: 'Texte',
tabBarIcon: ({ color }) => <TabBarIcon name="book" color={color} />,
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Einstellungen',
tabBarIcon: ({ color }) => <TabBarIcon name="cog" color={color} />,
}}
/>
</Tabs>
);
}

View file

@ -0,0 +1,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 }) => (
<TextListItem
item={item}
onShare={handleShare}
onDelete={handleDelete}
formatDate={formatDate}
getAudioDuration={getAudioDuration}
/>
);
if (loading) {
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<Header title="Meine Texte" showBackButton={false} />
<View className={`flex-1 items-center justify-center ${colors.background}`}>
<ActivityIndicator size="large" color="#3B82F6" />
<Text className={`mt-2 ${colors.textSecondary}`}>Texte werden geladen...</Text>
</View>
</>
);
}
if (error) {
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<Header title="Meine Texte" showBackButton={false} />
<View className={`flex-1 items-center justify-center px-4 ${colors.background}`}>
<Text className="mb-4 text-center text-red-600">{error}</Text>
<Pressable onPress={() => refetch()} className={`rounded-lg ${colors.primary} px-4 py-2`}>
<Text className="text-white">Erneut versuchen</Text>
</Pressable>
</View>
</>
);
}
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<Header title="Meine Texte" showBackButton={false} />
<View className={`flex-1 ${colors.background}`}>
<TagFilter />
{texts.length === 0 ? (
<View className="flex-1 items-center justify-center px-4">
<Text className={`mb-4 text-center ${colors.textTertiary}`}>
Noch keine Texte vorhanden
</Text>
<Pressable
onPress={() => router.push('/add-text')}
className={`rounded-lg ${colors.primary} px-6 py-3`}>
<Text className="font-semibold text-white">Ersten Text hinzufügen</Text>
</Pressable>
</View>
) : filteredTexts.length === 0 ? (
<View className="flex-1 items-center justify-center px-4">
<Text className={`mb-4 text-center ${colors.textTertiary}`}>
Keine Texte mit den gewählten Tags gefunden
</Text>
<Pressable
onPress={() => router.push('/add-text')}
className={`rounded-lg ${colors.primary} px-6 py-3`}>
<Text className="font-semibold text-white">Neuen Text hinzufügen</Text>
</Pressable>
</View>
) : (
<FlatList
data={filteredTexts}
renderItem={renderTextItem}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 16, paddingBottom: 100 }}
showsVerticalScrollIndicator={false}
/>
)}
<View
className={`absolute bottom-0 left-0 right-0 ${colors.surface} border-t ${colors.border} shadow-lg`}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 16 }}
className="flex-row">
<FloatingActionButton
onPress={() => router.push('/add-text')}
icon="+"
label="Neuer Text"
style={{ marginRight: 12 }}
/>
<FloatingActionButton
onPress={handleClipboardUrl}
icon="📋"
label={clipboardHasUrl ? 'URL einfügen' : 'Keine URL'}
disabled={!clipboardHasUrl}
loading={extracting}
style={{ marginRight: 12 }}
/>
</ScrollView>
</View>
</View>
</>
);
}

View file

@ -0,0 +1,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 (
<>
<Stack.Screen options={{ headerShown: false }} />
<Header title="Einstellungen" showBackButton={false} />
<ScrollView className={`flex-1 ${colors.background}`}>
<View className="p-4">
{/* Statistics */}
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>Statistiken</Text>
<View className="space-y-2">
<View className="flex-row justify-between">
<Text className={`${colors.textSecondary}`}>Texte gesamt:</Text>
<Text className={`${colors.text}`}>{totalTexts}</Text>
</View>
<View className="flex-row justify-between">
<Text className={`${colors.textSecondary}`}>Tags:</Text>
<Text className={`${colors.text}`}>{totalTags}</Text>
</View>
<View className="flex-row justify-between">
<Text className={`${colors.textSecondary}`}>Texte mit Audio:</Text>
<Text className={`${colors.text}`}>{textsWithAudio}</Text>
</View>
<View className="flex-row justify-between">
<Text className={`${colors.textSecondary}`}>Audio-Speicher:</Text>
<Text className={`${colors.text}`}>
{(totalAudioSize / 1024 / 1024).toFixed(2)} MB
</Text>
</View>
</View>
</View>
{/* Audio Settings */}
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>Audio-Einstellungen</Text>
<View className="mb-4">
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Stimme</Text>
<Dropdown
value={currentVoice}
onValueChange={(newVoice) => updateSettings({ voice: newVoice })}
placeholder="Stimme wählen"
title="Stimme auswählen"
groups={Object.entries(
GERMAN_VOICES.reduce(
(groups, voice) => {
const provider = voice.provider;
if (!groups[provider]) {
groups[provider] = {};
}
const quality = voice.quality;
if (!groups[provider][quality]) {
groups[provider][quality] = [];
}
groups[provider][quality].push(voice);
return groups;
},
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
)
).map(([provider, qualityGroups]) => ({
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
voices.map((voice) => ({
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
value: voice.value,
}))
),
}))}
/>
</View>
<View>
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>
Geschwindigkeit
</Text>
<View className="space-y-2">
{speeds.map((speed) => (
<Pressable
key={speed.value}
onPress={() => updateSettings({ speed: speed.value })}
className={`rounded-lg border p-3 ${
settings.speed === speed.value
? `border-blue-500 ${colors.primaryLight}`
: colors.border
}`}>
<Text
className={`${
settings.speed === speed.value ? 'text-blue-700' : colors.textSecondary
}`}>
{speed.label}
</Text>
</Pressable>
))}
</View>
</View>
</View>
{/* App Settings */}
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>App-Einstellungen</Text>
<View>
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Design</Text>
<View className="space-y-2">
{themes.map((theme) => (
<Pressable
key={theme.value}
onPress={() => updateSettings({ theme: theme.value as 'light' | 'dark' })}
className={`rounded-lg border p-3 ${
settings.theme === theme.value
? `border-blue-500 ${colors.primaryLight}`
: colors.border
}`}>
<Text
className={`${
settings.theme === theme.value ? 'text-blue-700' : colors.textSecondary
}`}>
{theme.label}
</Text>
</Pressable>
))}
</View>
</View>
</View>
{/* App Info */}
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
<Text className={`mb-3 text-lg font-semibold ${colors.text}`}>App Info</Text>
<View className="space-y-2">
<View className="flex-row justify-between">
<Text className={`${colors.textSecondary}`}>Version:</Text>
<Text className={`${colors.text}`}>1.0.0</Text>
</View>
<View className="flex-row justify-between">
<Text className={`${colors.textSecondary}`}>Build:</Text>
<Text className={`${colors.text}`}>1</Text>
</View>
</View>
</View>
{/* User Info */}
<View className={`mb-4 rounded-lg ${colors.surface} p-4`}>
<Text className={`mb-2 text-lg font-semibold ${colors.text}`}>Konto</Text>
<Text className={`mb-4 ${colors.textSecondary}`}>{user?.email}</Text>
<Pressable onPress={handleLogout} className={`rounded-lg ${colors.error} px-4 py-2`}>
<Text className="text-center font-semibold text-white">Abmelden</Text>
</Pressable>
</View>
</View>
</ScrollView>
</>
);
}

View file

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

View file

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

View file

@ -0,0 +1,35 @@
// Polyfill for structuredClone (not available in React Native 0.79.5)
import '../global.css';
import { Stack, router } from 'expo-router';
import { useAuth } from '~/hooks/useAuth';
import { useEffect } from 'react';
if (typeof globalThis.structuredClone === 'undefined') {
globalThis.structuredClone = (obj: any) => JSON.parse(JSON.stringify(obj));
}
export const unstable_settings = {
initialRouteName: '(tabs)',
};
export default function RootLayout() {
const { user, loading } = useAuth();
useEffect(() => {
if (!loading) {
if (user) {
router.replace('/(tabs)');
} else {
router.replace('/(auth)/login');
}
}
}, [user, loading]);
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
</Stack>
);
}

View file

@ -0,0 +1,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<string | null>(null);
const { createText, refetch } = useTexts();
const { colors } = useTheme();
const { settings } = useStore();
const [selectedVoice, setSelectedVoice] = useState(settings.voice || 'de-DE-Neural2-A');
const [inputMode, setInputMode] = useState<'text' | 'url'>('text');
const [url, setUrl] = useState('');
const [extracting, setExtracting] = useState(false);
const handleExtractUrl = async () => {
if (!url.trim()) {
setError('Bitte gib eine URL ein');
return;
}
setExtracting(true);
setError(null);
const { data, error: extractError } = await urlExtractorService.extractFromUrl(url);
setExtracting(false);
if (extractError) {
setError(extractError.message);
return;
}
if (data) {
setTitle(data.title);
setContent(urlExtractorService.formatExtractedContent(data));
if (data.tags.length > 0) {
setTags(data.tags.join(', '));
}
}
};
const handleSave = async () => {
if (!title.trim()) {
setError('Bitte gib einen Titel ein');
return;
}
if (!content.trim()) {
setError('Bitte gib einen Text ein');
return;
}
setLoading(true);
setError(null);
const tagsArray = tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
try {
const { data, error } = await createText(title.trim(), content.trim(), {
tags: tagsArray,
tts: { speed: settings.speed || 1.0, voice: selectedVoice },
source: inputMode === 'url' ? url : undefined,
});
if (error) {
console.error('Error creating text:', error);
setError(error);
setLoading(false);
} else {
console.log('Text created successfully:', data);
// Navigate back immediately - the list will refresh via useFocusEffect
router.back();
}
} catch (err) {
console.error('Unexpected error:', err);
setError(err instanceof Error ? err.message : 'Unerwarteter Fehler');
setLoading(false);
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className={`flex-1 ${colors.background}`}>
<Stack.Screen options={{ headerShown: false }} />
<Header
title="Neuer Text"
rightComponent={
<Pressable onPress={handleSave} disabled={loading}>
{loading ? (
<ActivityIndicator size="small" color="#3B82F6" />
) : (
<Text className="font-semibold text-blue-600">Speichern</Text>
)}
</Pressable>
}
/>
<ScrollView className="flex-1 p-4">
{error && (
<View className={`mb-4 rounded-lg border border-red-200 ${colors.errorLight} p-3`}>
<Text className="text-red-700">{error}</Text>
</View>
)}
<View className="mb-4">
<Text className={`mb-2 text-sm font-medium ${colors.text}`}>Titel</Text>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Titel des Textes"
className={`rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
autoFocus
/>
</View>
<View className="mb-4">
<Text className={`mb-2 text-sm font-medium ${colors.text}`}>
Tags (durch Komma getrennt)
</Text>
<TextInput
value={tags}
onChangeText={setTags}
placeholder="z.B. Roman, Favorit, Entspannung"
className={`rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
/>
</View>
<View className="mb-4">
<Text className={`mb-2 text-sm font-medium ${colors.text}`}>Stimme</Text>
<Dropdown
value={selectedVoice}
onValueChange={setSelectedVoice}
placeholder="Stimme wählen"
title="Stimme auswählen"
groups={Object.entries(
GERMAN_VOICES.reduce(
(groups, voice) => {
const provider = voice.provider;
if (!groups[provider]) {
groups[provider] = {};
}
const quality = voice.quality;
if (!groups[provider][quality]) {
groups[provider][quality] = [];
}
groups[provider][quality].push(voice);
return groups;
},
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
)
).map(([provider, qualityGroups]) => ({
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
voices.map((voice) => ({
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
value: voice.value,
}))
),
}))}
/>
</View>
<View className="mb-4">
<View className="mb-2 flex-row">
<Pressable
onPress={() => setInputMode('text')}
className={`mr-2 rounded-lg px-4 py-2 ${inputMode === 'text' ? colors.primary : colors.surface}`}>
<Text className={inputMode === 'text' ? 'font-medium text-white' : `${colors.text}`}>
Text
</Text>
</Pressable>
<Pressable
onPress={() => setInputMode('url')}
className={`rounded-lg px-4 py-2 ${inputMode === 'url' ? colors.primary : colors.surface}`}>
<Text className={inputMode === 'url' ? 'font-medium text-white' : `${colors.text}`}>
URL
</Text>
</Pressable>
</View>
{inputMode === 'text' ? (
<TextInput
value={content}
onChangeText={setContent}
placeholder="Füge hier deinen Text ein..."
multiline
textAlignVertical="top"
className={`min-h-[200px] rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
/>
) : (
<View>
<TextInput
value={url}
onChangeText={setUrl}
placeholder="https://example.com/artikel"
autoCapitalize="none"
autoCorrect={false}
className={`rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text} mb-2`}
/>
<Pressable
onPress={handleExtractUrl}
disabled={extracting || !url.trim()}
className={`mb-2 rounded-lg px-4 py-3 ${
extracting || !url.trim() ? 'bg-gray-300' : colors.primary
}`}>
{extracting ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text className="text-center font-medium text-white">Text extrahieren</Text>
)}
</Pressable>
{content && (
<TextInput
value={content}
onChangeText={setContent}
placeholder="Extrahierter Text..."
multiline
textAlignVertical="top"
className={`min-h-[150px] rounded-lg border ${colors.border} ${colors.surface} px-3 py-3 text-base ${colors.text}`}
/>
)}
</View>
)}
</View>
<View className={`mb-4 rounded-lg ${colors.surfaceSecondary} p-3`}>
<Text className={`text-sm ${colors.textSecondary}`}>
💡 Tipp: Du kannst später Audio für diesen Text generieren und offline anhören.
</Text>
</View>
<Pressable
onPress={handleSave}
disabled={loading}
className={`mb-4 rounded-lg px-4 py-3 ${loading ? 'bg-gray-400' : colors.primary}`}>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-center text-base font-semibold text-white">Speichern</Text>
)}
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}

View file

@ -0,0 +1,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<TextType | null>(null);
const [loading, setLoading] = useState(true);
const { colors } = useTheme();
useEffect(() => {
const foundText = texts.find((t) => t.id === id);
setText(foundText || null);
setLoading(false);
}, [id, texts]);
const handleDelete = () => {
Alert.alert(
'Text löschen',
'Möchtest du diesen Text wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Löschen',
style: 'destructive',
onPress: async () => {
if (text) {
const { error } = await deleteText(text.id);
if (!error) {
router.back();
}
}
},
},
]
);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (loading) {
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<Header title="Text wird geladen..." />
<View className={`flex-1 items-center justify-center ${colors.background}`}>
<ActivityIndicator size="large" color="#3B82F6" />
</View>
</>
);
}
if (!text) {
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<Header title="Text nicht gefunden" />
<View className={`flex-1 items-center justify-center px-4 ${colors.background}`}>
<Text variant="body" color="tertiary" align="center" className="mb-4">
Der angeforderte Text wurde nicht gefunden.
</Text>
<Pressable
onPress={() => router.back()}
className={`rounded-lg ${colors.primary} px-4 py-2`}>
<Text color="white">Zurück</Text>
</Pressable>
</View>
</>
);
}
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<Header
title={text.title}
rightComponent={
<Pressable
onPress={handleDelete}
className="-mr-2 rounded-full p-2"
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Icon name="delete" size={24} color="#6b7280" />
</Pressable>
}
/>
<ScrollView className={`flex-1 ${colors.background}`}>
<View className="p-4">
<View className="mb-4">
<Text variant="h3" className="mb-2">
{text.title}
</Text>
<View className="mb-2 flex-row items-center">
<Text variant="bodySmall" color="tertiary">
Erstellt: {formatDate(text.created_at)}
</Text>
{text.updated_at !== text.created_at && (
<Text variant="bodySmall" color="tertiary" className="ml-4">
Bearbeitet: {formatDate(text.updated_at)}
</Text>
)}
</View>
{text.data.tags && text.data.tags.length > 0 ? (
<View className="mb-4 flex-row flex-wrap">
{text.data.tags.map((tag, index) => (
<View
key={index}
className={`mb-2 mr-2 rounded-full ${colors.primaryLight} px-3 py-1`}>
<Text variant="bodySmall" color="blue">
{tag}
</Text>
</View>
))}
</View>
) : null}
</View>
<View className="mb-6">
<Text variant="body" className="leading-6">
{text.content}
</Text>
</View>
<AudioPlayer
text={text}
onAudioGenerated={() => {
// Refresh text data after audio generation
const updatedText = texts.find((t) => t.id === text.id);
if (updatedText) {
setText(updatedText);
}
}}
/>
{text.data.stats ? (
<View className={`mt-6 rounded-lg ${colors.surfaceSecondary} p-4`}>
<Text variant="h5" className="mb-3">
Statistiken
</Text>
<View>
<View className="flex-row justify-between">
<Text color="secondary">Wiedergaben:</Text>
<Text>{text.data.stats?.playCount || 0}</Text>
</View>
{text.data.stats?.totalTime ? (
<View className="flex-row justify-between">
<Text color="secondary">Gesamtzeit:</Text>
<Text>
{Math.floor(text.data.stats.totalTime / 60)}m {Math.round(text.data.stats.totalTime % 60)}s
</Text>
</View>
) : null}
<View className="flex-row justify-between">
<Text color="secondary">Status:</Text>
<Text>{text.data.stats?.completed ? 'Abgeschlossen' : 'In Progress'}</Text>
</View>
</View>
</View>
) : null}
{text.data.audio?.hasLocalCache ? (
<View className={`mt-6 rounded-lg ${colors.successLight} p-4`}>
<Text variant="h5" className="mb-3">
Audio Cache
</Text>
<View>
<View className="flex-row justify-between">
<Text color="secondary">Chunks:</Text>
<Text>{text.data.audio?.chunks?.length || 0}</Text>
</View>
<View className="flex-row justify-between">
<Text color="secondary">Größe:</Text>
<Text>{((text.data.audio?.totalSize || 0) / 1024 / 1024).toFixed(2)} MB</Text>
</View>
{text.data.audio?.lastGenerated ? (
<View className="flex-row justify-between">
<Text color="secondary">Generiert:</Text>
<Text>{formatDate(text.data.audio.lastGenerated)}</Text>
</View>
) : null}
</View>
</View>
) : null}
</View>
</ScrollView>
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,10 @@
module.exports = function (api) {
api.cache(true);
let plugins = [];
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

View file

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

View file

@ -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<string, keyof typeof Ionicons.glyphMap> = {
'doc.text': 'document-text-outline',
'play.circle': 'play-circle-outline',
'square.and.arrow.up': 'share-outline',
tag: 'pricetag-outline',
trash: 'trash-outline',
};
const showActionSheet = () => {
if (Platform.OS === 'ios') {
const optionTitles = options.map((opt) => opt.title);
const destructiveButtonIndex = options.findIndex((opt) => opt.destructive);
const disabledButtonIndices = options
.map((opt, idx) => (opt.disabled ? idx : -1))
.filter((idx) => idx !== -1);
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 (
<Pressable
onPress={() => !isDisabled && handleSelect(index)}
disabled={isDisabled}
className={`flex-row items-center px-4 py-4`}
style={({ pressed }) => ({
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
opacity: isDisabled ? 0.5 : 1,
})}>
{iconName && (
<Ionicons
name={iconName}
size={22}
color={
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
}
style={{ marginRight: 16 }}
/>
)}
<Text className={`text-lg ${isDestructive ? 'text-red-500' : colors.text}`}>
{item.title}
</Text>
</Pressable>
);
};
return (
<>
{React.cloneElement(children, {
onLongPress: showActionSheet,
delayLongPress: 500,
} as any)}
{Platform.OS !== 'ios' && (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={() => setVisible(false)}>
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
<View style={styles.backdrop} />
<View style={styles.container}>
<View className={`rounded-t-2xl ${colors.surface}`} style={styles.menu}>
{(title || message) && (
<View className={`border-b px-4 py-3 ${colors.border}`}>
{title && (
<Text className={`text-center font-semibold ${colors.text}`}>{title}</Text>
)}
{message && (
<Text className={`mt-1 text-center text-sm ${colors.textSecondary}`}>
{message}
</Text>
)}
</View>
)}
<FlatList
data={options}
renderItem={renderOption}
keyExtractor={(_, index) => index.toString()}
scrollEnabled={false}
ItemSeparatorComponent={() => <View className={`h-px ${colors.border}`} />}
/>
<View className={`border-t ${colors.border}`}>
<Pressable
onPress={() => setVisible(false)}
className="py-4"
style={({ pressed }) => ({
backgroundColor: pressed ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
})}>
<Text className="text-center text-lg font-medium text-blue-600">Abbrechen</Text>
</Pressable>
</View>
</View>
</View>
</Pressable>
</Modal>
)}
</>
);
}
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,
},
}),
},
});

View file

@ -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<AudioPlayerProps> = ({ text, onAudioGenerated }) => {
const [isGenerating, setIsGenerating] = useState(false);
const [showSpeedControl, setShowSpeedControl] = useState(false);
const [selectedVoice, setSelectedVoice] = useState<string>('');
const [showVersions, setShowVersions] = useState(false);
const progressBarRef = useRef<View>(null);
const pulseAnim = useRef(new Animated.Value(1)).current;
const { 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<string>(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 (
<View className={`rounded-lg ${colors.surface} p-3 shadow-sm`}>
{/* Voice selection and generate button - always visible */}
<View className="mb-4">
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Sprachauswahl</Text>
<Dropdown
options={[]}
value={selectedVoice}
onValueChange={handleVoiceChange}
placeholder="Wähle eine Stimme"
disabled={isGenerating}
title="Stimme auswählen"
groups={Object.entries(
GERMAN_VOICES.reduce(
(groups, voice) => {
const provider = voice.provider;
const quality = voice.quality;
if (!groups[provider]) {
groups[provider] = {};
}
if (!groups[provider][quality]) {
groups[provider][quality] = [];
}
groups[provider][quality].push(voice);
return groups;
},
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
)
).map(([provider, qualityGroups]) => ({
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
voices.map((voice) => ({
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
value: voice.value,
}))
),
}))}
/>
<Pressable
onPress={handleGenerateAudio}
disabled={isGenerating}
className={`mt-3 rounded-lg px-4 py-2.5 ${
isGenerating ? 'bg-gray-400' : colors.primary
}`}>
{isGenerating ? (
<View className="flex-row items-center justify-center">
<ActivityIndicator size="small" color="white" />
<Text className="ml-2 font-medium text-white">
{generationProgress?.currentChunk || 'Generiere Audio...'}
</Text>
</View>
) : (
<View className="flex-row items-center justify-center">
<Ionicons name="volume-high" size={20} color="white" />
<Text className="ml-2 font-medium text-white">
{hasAudio ? 'Audio neu generieren' : 'Audio generieren'}
</Text>
</View>
)}
</Pressable>
{generationProgress && (
<View className="mt-2">
<View className={`h-1.5 rounded-full ${colors.surfaceSecondary}`}>
<View
className={`h-1.5 rounded-full ${colors.primary}`}
style={{
width: `${(generationProgress.chunksCompleted / generationProgress.totalChunks) * 100}%`,
}}
/>
</View>
<Text className={`mt-1 text-xs ${colors.textSecondary}`}>
{generationProgress.chunksCompleted} / {generationProgress.totalChunks} Chunks
</Text>
</View>
)}
</View>
{/* Audio versions - only shown when audio exists */}
{audioVersions.length > 0 && (
<View className="mt-4">
<Pressable
onPress={() => setShowVersions(!showVersions)}
className="flex-row items-center justify-between">
<Text className={`text-sm font-medium ${colors.textSecondary}`}>
Audio-Versionen ({audioVersions.length})
</Text>
<Ionicons
name={showVersions ? 'chevron-up' : 'chevron-down'}
size={16}
color="#71717a"
/>
</Pressable>
{showVersions && (
<ScrollView className="mt-2 max-h-40">
{audioVersions.map((version) => {
const voice = getVoiceById(version.settings.voice);
const isActive = version.id === selectedVersionId;
const date = new Date(version.createdAt);
return (
<Pressable
key={version.id}
onPress={() => setSelectedVersionId(version.id)}
className={`mb-2 rounded-lg p-3 ${
isActive ? 'bg-blue-600' : colors.surfaceSecondary
}`}>
<View className="flex-row items-center justify-between">
<View className="flex-1">
<Text className={`text-sm ${isActive ? 'text-white' : colors.text}`}>
{date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
<Text
className={`text-xs ${isActive ? 'text-blue-100' : colors.textSecondary}`}>
{voice?.label || version.settings.voice} {version.settings.speed}x
</Text>
</View>
<View className="flex-row items-center">
{isActive && <Text className="mr-2 text-xs text-white">Aktiv</Text>}
<Ionicons
name={isActive ? 'radio-button-on' : 'radio-button-off'}
size={20}
color={isActive ? 'white' : '#71717a'}
/>
</View>
</View>
</Pressable>
);
})}
</ScrollView>
)}
</View>
)}
{/* Audio player - only shown when audio exists */}
{hasAudio && (
<View className="mt-4 border-t border-zinc-800 pt-3">
{/* </View> closing tag moved to end */}
{/* Progress bar and time info - full width */}
<View className="mb-3">
{/* Progress Bar with touch gestures */}
<Pressable onPress={handleProgressPress} className="py-2">
<View
ref={progressBarRef}
className={`h-2 rounded-full ${colors.surfaceSecondary} overflow-hidden`}>
<View
className={`h-2 rounded-full ${colors.primary}`}
style={{
width:
totalDuration > 0
? `${(audioState.currentPosition / totalDuration) * 100}%`
: '0%',
}}
/>
{/* Scrubber indicator */}
{totalDuration > 0 && (
<View
className="absolute top-0 h-2"
style={{
left: `${(audioState.currentPosition / totalDuration) * 100}%`,
}}>
<View
className={`h-3 w-3 rounded-full ${colors.primary} shadow-lg`}
style={{ marginTop: -2, marginLeft: -6 }}
/>
</View>
)}
</View>
</Pressable>
{/* Time display */}
<View className="mt-1 flex-row justify-between">
<Text className={`text-xs ${colors.textTertiary}`}>
{formatTime(audioState.currentPosition)}
</Text>
<Text className={`text-xs ${colors.textTertiary}`}>{formatTime(totalDuration)}</Text>
</View>
</View>
{/* Controls row */}
<View className="flex-row items-center justify-center">
{/* Stop button */}
<Pressable
onPress={handleStop}
disabled={audioState.isLoading}
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}>
<Ionicons name="stop" size={18} color="#6b7280" />
</Pressable>
{/* Backward 15s button */}
<Pressable
onPress={() => seekBackward(15)}
disabled={audioState.isLoading || !audioState.sound}
className={`rounded-full ${colors.surfaceSecondary} mr-2 p-2`}>
<View className="relative" style={{ transform: [{ scaleX: -1 }] }}>
<Ionicons name="reload" size={18} color="#6b7280" />
<View
className="absolute -bottom-1 -left-1"
style={{ transform: [{ scaleX: -1 }] }}>
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
</View>
</View>
</Pressable>
{/* Play/Pause button */}
<Animated.View
style={{
transform: [{ scale: audioState.isLoading ? pulseAnim : 1 }],
}}>
<Pressable
onPress={handlePlayPause}
disabled={audioState.isLoading}
className={`rounded-full ${colors.primary} mx-2 p-2.5`}>
{audioState.isLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<Ionicons
name={audioState.isPlaying ? 'pause' : 'play'}
size={20}
color="white"
/>
)}
</Pressable>
</Animated.View>
{/* Forward 15s button */}
<Pressable
onPress={() => seekForward(15)}
disabled={audioState.isLoading || !audioState.sound}
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}>
<View className="relative">
<Ionicons name="reload" size={18} color="#6b7280" />
<View className="absolute -bottom-1 -right-1">
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
</View>
</View>
</Pressable>
{/* Speed control button */}
<Pressable
onPress={() => setShowSpeedControl(!showSpeedControl)}
className={`rounded-full ${colors.surfaceSecondary} px-3 py-1.5`}>
<Text style={{ fontSize: 14, color: '#6b7280', fontWeight: '600' }}>
{audioState.playbackRate}x
</Text>
</Pressable>
</View>
{/* Speed options dropdown */}
{showSpeedControl && (
<View className="mt-2 flex-row justify-center">
<View className={`rounded-lg ${colors.surfaceSecondary} flex-row p-2`}>
{speedOptions.map((speed) => (
<Pressable
key={speed}
onPress={() => handleSpeedChange(speed)}
className={`mx-1 rounded px-3 py-1 ${
audioState.playbackRate === speed ? colors.primary : ''
}`}>
<Text
style={{
fontSize: 12,
color: audioState.playbackRate === speed ? '#ffffff' : '#6b7280',
fontWeight: audioState.playbackRate === speed ? 'bold' : 'normal',
}}>
{speed}x
</Text>
</Pressable>
))}
</View>
</View>
)}
</View>
)}
</View>
);
};

View file

@ -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<PressableProps, 'children'> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: IconName;
iconPosition?: 'left' | 'right';
loading?: boolean;
disabled?: boolean;
fullWidth?: boolean;
className?: string;
children?: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
icon,
iconPosition = 'left',
loading = false,
disabled = false,
fullWidth = false,
className,
children,
...props
}) => {
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 <ActivityIndicator size="small" color={getIconColor()} />;
}
const iconElement = icon ? (
<Icon name={icon} size={getIconSize()} color={getIconColor()} />
) : null;
const textElement = children ? (
<Text variant={getTextVariant()} color={getTextColor()} align="center">
{children}
</Text>
) : null;
if (!icon && !children) {
return null;
}
if (icon && !children) {
return iconElement;
}
if (!icon && children) {
return textElement;
}
return (
<View className="flex-row items-center gap-2">
{iconPosition === 'left' && iconElement}
{textElement}
{iconPosition === 'right' && iconElement}
</View>
);
};
const buttonClasses = [
'rounded-lg items-center justify-center',
getSizeClasses(),
getVariantClasses(),
fullWidth ? 'w-full' : '',
isDisabled ? 'opacity-50' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<Pressable className={buttonClasses} disabled={isDisabled} {...props}>
{renderContent()}
</Pressable>
);
};

View file

@ -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<View>(null);
const { colors } = useTheme();
const handleLongPress = () => {
childRef.current?.measure((x, y, width, height, pageX, pageY) => {
const screenHeight = Dimensions.get('window').height;
const menuHeight = actions.length * 50 + 20; // Approximate menu height
// 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<string, keyof typeof Ionicons.glyphMap> = {
'doc.text': 'document-text-outline',
'play.circle': 'play-circle-outline',
'square.and.arrow.up': 'share-outline',
tag: 'pricetag-outline',
trash: 'trash-outline',
};
const renderAction = ({ item, index }: { item: ContextMenuAction; index: number }) => {
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
const isDisabled = item.disabled;
const isDestructive = item.destructive;
return (
<Pressable
onPress={() => !isDisabled && handleActionPress(index)}
disabled={isDisabled}
className={`flex-row items-center px-4 py-3 ${
index < actions.length - 1 ? `border-b ${colors.border}` : ''
}`}
style={({ pressed }) => ({
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
opacity: isDisabled ? 0.5 : 1,
})}>
{iconName && (
<Ionicons
name={iconName}
size={20}
color={
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
}
style={{ marginRight: 12 }}
/>
)}
<Text className={`text-base ${isDestructive ? 'text-red-500' : colors.text}`}>
{item.title}
</Text>
</Pressable>
);
};
return (
<>
<View ref={childRef} collapsable={false}>
{React.cloneElement(children, {
onLongPress: handleLongPress,
delayLongPress: 500,
} as any)}
</View>
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={() => setVisible(false)}>
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
<View style={[styles.backdrop, { backgroundColor: 'rgba(0, 0, 0, 0.3)' }]} />
<View
style={[
styles.menu,
{
top: menuPosition.y,
left: 20,
right: 20,
maxWidth: 300,
alignSelf: 'center',
backgroundColor: colors.text.includes('white') ? '#1f2937' : '#ffffff',
},
]}
className={`rounded-lg shadow-lg ${colors.surface}`}>
<FlatList
data={actions}
renderItem={renderAction}
keyExtractor={(_, index) => index.toString()}
scrollEnabled={false}
/>
</View>
</Pressable>
</Modal>
</>
);
}
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,
},
}),
},
});

View file

@ -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 (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
};
const styles = {
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
};

View file

@ -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 (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={style}
className={`flex-row items-center rounded-full px-4 py-3 shadow-lg ${
disabled || loading ? 'bg-gray-400' : colors.primary
}`}>
{loading ? (
<ActivityIndicator size="small" color="white" />
) : (
<>
<Text className="mr-2 text-lg text-white">{icon}</Text>
<Text className="font-medium text-white">{label}</Text>
</>
)}
</Pressable>
);
}

View file

@ -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<HeaderProps> = ({
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 (
<View
style={{
backgroundColor: headerBackgroundColor,
paddingTop: insets.top,
paddingBottom: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: borderColor,
}}>
<StatusBar
barStyle={isDark ? 'light-content' : 'dark-content'}
backgroundColor={headerBackgroundColor}
/>
<View className="min-h-[44px] flex-row items-center justify-between">
{/* Left side - Back button */}
<View className="flex-1 flex-row items-center">
{showBackButton && (
<Pressable
onPress={handleBackPress}
className="-ml-2 mr-3 rounded-full p-2"
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Icon name="arrow-back" size={24} color={headerTextColor} />
</Pressable>
)}
</View>
{/* Center - Title */}
<View className="flex-2 items-center">
{title && (
<Text
variant="h4"
color={headerTextColor === '#000000' ? 'black' : 'white'}
className="text-center font-semibold"
numberOfLines={1}
ellipsizeMode="tail">
{title}
</Text>
)}
</View>
{/* Right side - Custom component */}
<View className="flex-1 flex-row items-center justify-end">{rightComponent}</View>
</View>
</View>
);
};

View file

@ -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<IconName, keyof typeof Ionicons.glyphMap> = {
add: 'add',
delete: 'trash',
edit: 'pencil',
save: 'save',
close: 'close',
back: 'arrow-back',
play: 'play',
pause: 'pause',
stop: 'stop',
refresh: 'refresh',
settings: 'settings',
logout: 'log-out',
eye: 'eye',
'eye-off': 'eye-off',
heart: 'heart',
'heart-outline': 'heart-outline',
tag: 'pricetag',
filter: 'filter',
search: 'search',
download: 'download',
share: 'share',
'volume-high': 'volume-high',
'volume-low': 'volume-low',
'volume-mute': 'volume-mute',
'fast-forward': 'play-forward',
rewind: 'play-back',
'skip-forward': 'play-skip-forward',
'skip-backward': 'play-skip-back',
checkmark: 'checkmark',
'close-circle': 'close-circle',
'alert-circle': 'alert-circle',
'information-circle': 'information-circle',
'chevron-down': 'chevron-down',
'chevron-up': 'chevron-up',
'chevron-left': 'chevron-back',
'chevron-right': 'chevron-forward',
'arrow-back': 'arrow-back',
'arrow-forward': 'arrow-forward',
home: 'home',
library: 'library',
person: 'person',
menu: 'menu',
'more-horizontal': 'ellipsis-horizontal',
'more-vertical': 'ellipsis-vertical',
'replay-15': 'refresh-circle',
'forward-15': 'add-circle',
'play-circle': 'play-circle',
'pause-circle': 'pause-circle',
'mic-circle': 'mic-circle',
};
export const Icon: React.FC<IconProps> = ({ name, size = 24, color = '#000000', className }) => {
const ionIconName = iconMapping[name];
if (!ionIconName) {
console.warn(`Icon "${name}" not found in iconMapping`);
return null;
}
return (
<View className={className}>
<Ionicons name={ionIconName} size={size} color={color} />
</View>
);
};

View file

@ -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<MinimalAudioPlayerProps> = ({ 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 (
<Pressable
onPress={handlePlayPause}
disabled={isLoading || isGenerating}
className={`rounded-full p-2 ${
hasAudio ? colors.surfaceSecondary : colors.surface
} active:opacity-70`}>
{isLoading || isGenerating ? (
<ActivityIndicator size="small" color={colors.tabBarInactive} />
) : (
<Icon
name={hasAudio ? (isPlaying ? 'pause-circle' : 'play-circle') : 'mic-circle'}
size={28}
color={hasAudio ? colors.tabBarActive : colors.tabBarInactive}
/>
)}
</Pressable>
);
};

View file

@ -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 (
<View className={styles.container}>
<Text className={styles.title}>{title}</Text>
<View className={styles.separator} />
<EditScreenInfo path={path} />
{children}
</View>
);
};
const styles = {
container: `items-center flex-1 justify-center`,
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
title: `text-xl font-bold`,
};

View file

@ -0,0 +1,15 @@
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { StyleSheet } from 'react-native';
export const TabBarIcon = (props: {
name: React.ComponentProps<typeof FontAwesome>['name'];
color: string;
}) => {
return <FontAwesome size={28} style={styles.tabBarIcon} {...props} />;
};
export const styles = StyleSheet.create({
tabBarIcon: {
marginBottom: -3,
},
});

View file

@ -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 (
<View className={`border-b ${colors.border} ${colors.surface} px-4 py-2`}>
<View className="mb-2 flex-row items-center justify-between">
<Text className={`text-sm font-medium ${colors.textSecondary}`}>Tags filtern:</Text>
{selectedTags.length > 0 && (
<Pressable onPress={clearTags}>
<Text className="text-sm text-blue-600">Alle entfernen</Text>
</Pressable>
)}
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingRight: 16 }}>
{allTags.map((tag) => {
const isSelected = selectedTags.includes(tag);
return (
<Pressable
key={tag}
onPress={() => toggleTag(tag)}
className={`mr-2 rounded-full border px-3 py-1 ${
isSelected
? `border-blue-500 ${colors.primaryLight}`
: `${colors.borderSecondary} ${colors.surfaceSecondary}`
}`}>
<Text className={`text-sm ${isSelected ? 'text-blue-800' : colors.textSecondary}`}>
{tag}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
);
};

View file

@ -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<TextVariant, string> = {
h1: 'text-4xl font-bold',
h2: 'text-3xl font-bold',
h3: 'text-2xl font-bold',
h4: 'text-xl font-bold',
h5: 'text-lg font-bold',
h6: 'text-base font-bold',
body: 'text-base',
bodyLarge: 'text-lg',
bodySmall: 'text-sm',
caption: 'text-xs',
label: 'text-sm font-medium',
labelLarge: 'text-base font-medium',
labelSmall: 'text-xs font-medium',
button: 'text-base font-semibold',
buttonSmall: 'text-sm font-semibold',
overline: 'text-xs font-medium uppercase tracking-wide',
subtitle1: 'text-base font-medium',
subtitle2: 'text-sm font-medium',
};
const colorStyles: Record<TextColor, string> = {
primary: 'text-blue-600',
secondary: 'text-gray-600',
accent: 'text-purple-600',
error: 'text-red-600',
warning: 'text-yellow-600',
success: 'text-green-600',
info: 'text-blue-500',
white: 'text-white',
black: 'text-black',
gray: 'text-gray-500',
muted: 'text-gray-400',
red: 'text-red-600',
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
purple: 'text-purple-600',
pink: 'text-pink-600',
indigo: 'text-indigo-600',
cyan: 'text-cyan-600',
orange: 'text-orange-600',
};
const weightStyles: Record<string, string> = {
light: 'font-light',
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
const alignStyles: Record<string, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
export const Text: React.FC<TextComponentProps> = ({
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 (
<RNText className={combinedClassName} {...props}>
{children}
</RNText>
);
};

View file

@ -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<TextListItemProps> = ({
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 (
<ActionMenu
options={[
{ title: 'Öffnen', systemIcon: 'doc.text' },
{ title: 'Teilen', systemIcon: 'square.and.arrow.up' },
{ title: 'Tags bearbeiten', systemIcon: 'tag' },
{ title: 'Löschen', systemIcon: 'trash', destructive: true },
]}
onSelect={handleMenuSelect}>
<Pressable
onPress={() => router.push(`/text/${item.id}`)}
className={`mb-3 rounded-lg border ${colors.border} ${colors.surface} p-4 shadow-sm`}>
{/* Header with title and date/duration */}
<View className="mb-2 flex-row items-start justify-between">
<Text className={`mr-2 flex-1 text-lg font-semibold ${colors.text}`} numberOfLines={1}>
{item.title}
</Text>
<View className="flex-row items-center">
<Text className={`text-sm ${colors.textTertiary}`}>{formatDate(item.updated_at)}</Text>
{getAudioDuration(item) && (
<>
<Text className={`mx-1 text-sm ${colors.textTertiary}`}></Text>
<Text className={`text-sm ${colors.textTertiary}`}>{getAudioDuration(item)}</Text>
</>
)}
</View>
</View>
{/* Content preview */}
<Text className={`mb-3 ${colors.textSecondary}`} numberOfLines={2}>
{item.content}
</Text>
{/* Footer with tags and audio player */}
<View className="flex-row items-center justify-between">
<View className="flex-row items-center">
{item.data.tags?.map((tag, index) => (
<View key={index} className={`mr-2 rounded-full ${colors.primaryLight} px-2 py-1`}>
<Text className="text-xs text-blue-800">{tag}</Text>
</View>
))}
</View>
<View className="flex-row items-center">
<MinimalAudioPlayer text={item} />
</View>
</View>
</Pressable>
</ActionMenu>
);
};

View file

@ -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 (
<View>
<TouchableOpacity
onPress={() => !disabled && setIsOpen(true)}
className={`flex-row items-center justify-between rounded-lg border ${colors.border} ${colors.surface} px-4 py-3 ${
disabled ? 'opacity-50' : ''
}`}
disabled={disabled}>
<Text
className={`flex-1 ${selectedOption ? colors.text : colors.textSecondary}`}
numberOfLines={1}>
{selectedOption?.label || placeholder}
</Text>
<Ionicons
name={isOpen ? 'chevron-up' : 'chevron-down'}
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={() => setIsOpen(false)}>
<Pressable
style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)' }}
onPress={() => setIsOpen(false)}>
<View className="flex-1 justify-center px-4">
<Pressable
onPress={(e) => e.stopPropagation()}
className={`max-h-[80%] rounded-xl border ${colors.border} ${colors.surface} shadow-xl`}>
<View className={`border-b ${colors.border} px-4 py-3`}>
<View className="flex-row items-center justify-between">
<Text className={`text-lg font-semibold ${colors.text}`}>{title}</Text>
<TouchableOpacity onPress={() => setIsOpen(false)}>
<Ionicons name="close-circle" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</View>
<ScrollView className="px-2 py-2" showsVerticalScrollIndicator={true}>
{groups
? // Render grouped options
groups.map((group, groupIndex) => (
<View key={group.title} className={groupIndex > 0 ? 'mt-4' : ''}>
<Text className={`mx-2 mb-2 text-sm font-bold ${colors.textSecondary}`}>
{group.title}
</Text>
{group.options.map((option) => (
<TouchableOpacity
key={option.value}
onPress={() => handleSelect(option.value)}
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
option.value === value ? colors.primary : colors.surfaceSecondary
}`}>
<Text
className={`${
option.value === value ? 'font-medium text-white' : colors.text
}`}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
))
: // Render flat options
options.map((option) => (
<TouchableOpacity
key={option.value}
onPress={() => handleSelect(option.value)}
className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
option.value === value ? colors.primary : colors.surfaceSecondary
}`}>
<Text
className={`${
option.value === value ? 'font-medium text-white' : colors.text
}`}>
{option.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
</View>
);
}

View file

@ -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<Voice['quality'], string> = {
premium: '🌟 Premium',
neural: '🧠 Neural',
wavenet: '🌊 WaveNet',
studio: '🎙️ Studio',
standard: '📢 Standard',
};
export const PROVIDER_LABELS: Record<VoiceProvider, string> = {
google: '🔵 Google Cloud',
elevenlabs: '🎯 ElevenLabs',
openai: '🤖 OpenAI',
};
// Backward compatibility: map old voice codes to new voice IDs
export const LEGACY_VOICE_MAP: Record<string, string> = {
'de-DE': 'de-DE-Neural2-A',
'en-US': 'en-US-Neural2-A',
'en-GB': 'en-GB-Neural2-A',
// Also map old voice IDs that no longer exist
'de-DE-Neural2-G': 'de-DE-Neural2-A',
'de-DE-Neural2-H': 'de-DE-Neural2-B',
'de-DE-Wavenet-G': 'de-DE-Wavenet-A',
'de-DE-Wavenet-H': 'de-DE-Wavenet-B',
'de-DE-Standard-G': 'de-DE-Standard-A',
'de-DE-Standard-H': 'de-DE-Standard-B',
};

View file

@ -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": ["<all_urls>"],
"js": ["content.js"]
}]
}
```
### Content Script
```javascript
// content.js
function extractArticle() {
// Nutze Readability direkt im Browser
const documentClone = document.cloneNode(true);
const reader = new Readability(documentClone);
const article = reader.parse();
if (article) {
// Sende an Reader App
const readerUrl = `reader-app://add?title=${encodeURIComponent(article.title)}&content=${encodeURIComponent(article.content)}`;
window.location.href = readerUrl;
}
}
```
### 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
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY(extensionItems, $e, SUBQUERY($e.attachments, $a, $a.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url").@count > 0).@count > 0</string>
</dict>
</dict>
```
### 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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -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<AudioState>({
isPlaying: false,
isLoading: false,
currentPosition: 0,
duration: 0,
playbackRate: settings.playbackRate || 1.0,
});
const [generationProgress, setGenerationProgress] = useState<AudioGenerationProgress | null>(
null
);
const [downloadProgress, setDownloadProgress] = useState<{
completed: number;
total: number;
currentChunk: string;
} | null>(null);
const { 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,
};
};

View file

@ -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<Session | null>(null);
const [loading, setLoading] = useState(true);
const { setUser } = useStore();
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session?.user) {
setUser({
id: session.user.id,
email: session.user.email!,
});
}
setLoading(false);
});
// 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,
};
};

View file

@ -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<Text[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<TextData>) => {
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<Text>) => {
try {
const { data, error } = await supabase
.from('texts')
.update(updates)
.eq('id', textId)
.select()
.single();
if (error) throw error;
return { data, error: null };
} catch (err) {
return {
data: null,
error: err instanceof Error ? err.message : 'Fehler beim Aktualisieren',
};
}
};
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<string>();
texts.forEach((text) => {
text.data.tags?.forEach((tag) => tagSet.add(tag));
});
return Array.from(tagSet).sort();
};
return {
texts,
loading,
error,
createText,
updateText,
deleteText,
updatePosition,
getTextsByTag,
getAllTags,
refetch: fetchTexts,
};
};

View file

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

View file

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

View file

@ -0,0 +1,3 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

View file

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

View file

@ -0,0 +1,10 @@
module.exports = {
printWidth: 100,
tabWidth: 2,
singleQuote: true,
bracketSameLine: true,
trailingComma: 'es5',
plugins: [require.resolve('prettier-plugin-tailwindcss')],
tailwindAttributes: ['className'],
};

View file

@ -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<void> {
try {
await FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true });
} catch {
// Directory might already exist
}
}
// Generate audio for a text using Supabase Edge Function
async generateAudioForText(
textId: string,
content: string,
voice: string = 'de-DE',
speed: number = 1.0,
chunkSize: number = 1000,
onProgress?: (progress: AudioGenerationProgress) => void,
versionId?: string
): Promise<{ success: boolean; error?: string; chunks?: AudioChunk[] }> {
try {
// Estimate number of chunks for progress tracking
const estimatedChunks = Math.ceil(content.length / chunkSize);
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<void> {
try {
for (const chunk of chunks) {
const filePath = `${AUDIO_DIR}${chunk.filename}`;
try {
await FileSystem.deleteAsync(filePath);
} catch (deleteError) {
console.log(`Could not delete ${chunk.filename}:`, deleteError);
}
}
} catch (error) {
console.error('Error clearing audio cache:', error);
}
}
// Get total cache size
async getCacheSize(): Promise<number> {
try {
const files = await FileSystem.readDirectoryAsync(AUDIO_DIR);
let totalSize = 0;
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<boolean> {
try {
for (const chunk of chunks) {
const filePath = `${AUDIO_DIR}${chunk.filename}`;
const fileInfo = await FileSystem.getInfoAsync(filePath);
if (!fileInfo.exists) {
return false;
}
}
return true;
} catch {
return false;
}
}
// Get file path for a chunk
getChunkFilePath(textId: string, chunkId: string): string {
return `${AUDIO_DIR}${textId}_${chunkId}.mp3`;
}
}

View file

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

View file

@ -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<AppState['settings']>) => void;
// Audio Player
currentTextId: string | null;
isPlaying: boolean;
currentPosition: number;
setCurrentText: (textId: string | null) => void;
setIsPlaying: (playing: boolean) => void;
setCurrentPosition: (position: number) => void;
// UI State
selectedTags: string[];
toggleTag: (tag: string) => void;
clearTags: () => void;
}
export const useStore = create<AppState>()(
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,
}),
}
)
);

View file

@ -0,0 +1 @@
v2.31.4

View file

@ -0,0 +1 @@
v2.177.0

View file

@ -0,0 +1 @@
postgresql://postgres.tiecnhktvovcqsrnunko:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres

View file

@ -0,0 +1 @@
17.4.1.054

View file

@ -0,0 +1 @@
tiecnhktvovcqsrnunko

View file

@ -0,0 +1 @@
v12.2.3

View file

@ -0,0 +1 @@
custom-metadata

View file

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

View file

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

View file

@ -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<string, string> = {
'de-DE': 'de-DE-Neural2-A',
'en-US': 'en-US-Neural2-A',
'en-GB': 'en-GB-Neural2-A',
};
return legacyVoiceMap[voiceId] || 'de-DE-Neural2-A';
}
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<string, string> = {
eleven_multilingual_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel
eleven_multilingual_v1: 'pNInz6obpgDQGcFmaJgB', // Adam
eleven_turbo_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel Turbo
eleven_monolingual_v1: '2EiwWnXFnvU5JabPnv8n', // Clyde
};
const elevenLabsVoiceId = voiceMapping[voice] || '21m00Tcm4TlvDq8ikWAM';
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 };
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

810
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff