mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
5b1e12e5d6
commit
58a342b407
82 changed files with 9831 additions and 7 deletions
40
apps/reader/.gitignore
vendored
Normal file
40
apps/reader/.gitignore
vendored
Normal 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
141
apps/reader/CLAUDE.md
Normal 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
|
||||
16
apps/reader/apps/mobile/.mcp.json
Normal file
16
apps/reader/apps/mobile/.mcp.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
apps/reader/apps/mobile/CONTEXT_MENU_SOLUTION.md
Normal file
58
apps/reader/apps/mobile/CONTEXT_MENU_SOLUTION.md
Normal 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.
|
||||
78
apps/reader/apps/mobile/ReadMe/AudioPlayerImprovements.md
Normal file
78
apps/reader/apps/mobile/ReadMe/AudioPlayerImprovements.md
Normal 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
|
||||
226
apps/reader/apps/mobile/ReadMe/ExpoUI.md
Normal file
226
apps/reader/apps/mobile/ReadMe/ExpoUI.md
Normal 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';
|
||||
570
apps/reader/apps/mobile/ReadMe/MinimalDatabase.md
Normal file
570
apps/reader/apps/mobile/ReadMe/MinimalDatabase.md
Normal 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.
|
||||
217
apps/reader/apps/mobile/ReadMe/ProjectOverview.md
Normal file
217
apps/reader/apps/mobile/ReadMe/ProjectOverview.md
Normal 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
2
apps/reader/apps/mobile/app-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
47
apps/reader/apps/mobile/app.json
Normal file
47
apps/reader/apps/mobile/app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
apps/reader/apps/mobile/app/(auth)/_layout.tsx
Normal file
15
apps/reader/apps/mobile/app/(auth)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/reader/apps/mobile/app/(auth)/forgot-password.tsx
Normal file
120
apps/reader/apps/mobile/app/(auth)/forgot-password.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
apps/reader/apps/mobile/app/(auth)/login.tsx
Normal file
125
apps/reader/apps/mobile/app/(auth)/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/reader/apps/mobile/app/(auth)/register.tsx
Normal file
144
apps/reader/apps/mobile/app/(auth)/register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/reader/apps/mobile/app/(tabs)/_layout.tsx
Normal file
34
apps/reader/apps/mobile/app/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
338
apps/reader/apps/mobile/app/(tabs)/index.tsx
Normal file
338
apps/reader/apps/mobile/app/(tabs)/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
211
apps/reader/apps/mobile/app/(tabs)/two.tsx
Normal file
211
apps/reader/apps/mobile/app/(tabs)/two.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
apps/reader/apps/mobile/app/+html.tsx
Normal file
46
apps/reader/apps/mobile/app/+html.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
24
apps/reader/apps/mobile/app/+not-found.tsx
Normal file
24
apps/reader/apps/mobile/app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center p-5`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
};
|
||||
35
apps/reader/apps/mobile/app/_layout.tsx
Normal file
35
apps/reader/apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Polyfill for structuredClone (not available in React Native 0.79.5)
|
||||
import '../global.css';
|
||||
|
||||
import { Stack, router } from 'expo-router';
|
||||
import { useAuth } from '~/hooks/useAuth';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
if (typeof globalThis.structuredClone === 'undefined') {
|
||||
globalThis.structuredClone = (obj: any) => JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (user) {
|
||||
router.replace('/(tabs)');
|
||||
} else {
|
||||
router.replace('/(auth)/login');
|
||||
}
|
||||
}
|
||||
}, [user, loading]);
|
||||
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(auth)" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
269
apps/reader/apps/mobile/app/add-text.tsx
Normal file
269
apps/reader/apps/mobile/app/add-text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
apps/reader/apps/mobile/app/text/[id].tsx
Normal file
214
apps/reader/apps/mobile/app/text/[id].tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/reader/apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/reader/apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/reader/apps/mobile/assets/favicon.png
Normal file
BIN
apps/reader/apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/reader/apps/mobile/assets/icon.png
Normal file
BIN
apps/reader/apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/reader/apps/mobile/assets/splash.png
Normal file
BIN
apps/reader/apps/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
10
apps/reader/apps/mobile/babel.config.js
Normal file
10
apps/reader/apps/mobile/babel.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
46
apps/reader/apps/mobile/cesconfig.jsonc
Normal file
46
apps/reader/apps/mobile/cesconfig.jsonc
Normal 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"
|
||||
}
|
||||
}
|
||||
186
apps/reader/apps/mobile/components/ActionMenu.tsx
Normal file
186
apps/reader/apps/mobile/components/ActionMenu.tsx
Normal 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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
469
apps/reader/apps/mobile/components/AudioPlayer.tsx
Normal file
469
apps/reader/apps/mobile/components/AudioPlayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
207
apps/reader/apps/mobile/components/Button.tsx
Normal file
207
apps/reader/apps/mobile/components/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
156
apps/reader/apps/mobile/components/ContextMenu.tsx
Normal file
156
apps/reader/apps/mobile/components/ContextMenu.tsx
Normal 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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
29
apps/reader/apps/mobile/components/EditScreenInfo.tsx
Normal file
29
apps/reader/apps/mobile/components/EditScreenInfo.tsx
Normal 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`,
|
||||
};
|
||||
42
apps/reader/apps/mobile/components/FloatingActionButton.tsx
Normal file
42
apps/reader/apps/mobile/components/FloatingActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/reader/apps/mobile/components/Header.tsx
Normal file
89
apps/reader/apps/mobile/components/Header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
128
apps/reader/apps/mobile/components/Icon.tsx
Normal file
128
apps/reader/apps/mobile/components/Icon.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
apps/reader/apps/mobile/components/MinimalAudioPlayer.tsx
Normal file
90
apps/reader/apps/mobile/components/MinimalAudioPlayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
apps/reader/apps/mobile/components/ScreenContent.tsx
Normal file
25
apps/reader/apps/mobile/components/ScreenContent.tsx
Normal 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`,
|
||||
};
|
||||
15
apps/reader/apps/mobile/components/TabBarIcon.tsx
Normal file
15
apps/reader/apps/mobile/components/TabBarIcon.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
53
apps/reader/apps/mobile/components/TagFilter.tsx
Normal file
53
apps/reader/apps/mobile/components/TagFilter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
158
apps/reader/apps/mobile/components/Text.tsx
Normal file
158
apps/reader/apps/mobile/components/Text.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
93
apps/reader/apps/mobile/components/TextListItem.tsx
Normal file
93
apps/reader/apps/mobile/components/TextListItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
135
apps/reader/apps/mobile/components/dropdown.tsx
Normal file
135
apps/reader/apps/mobile/components/dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
386
apps/reader/apps/mobile/constants/voices.ts
Normal file
386
apps/reader/apps/mobile/constants/voices.ts
Normal 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',
|
||||
};
|
||||
116
apps/reader/apps/mobile/docs/browser-extension-concept.md
Normal file
116
apps/reader/apps/mobile/docs/browser-extension-concept.md
Normal 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
|
||||
270
apps/reader/apps/mobile/docs/deployment-guide.md
Normal file
270
apps/reader/apps/mobile/docs/deployment-guide.md
Normal 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
|
||||
126
apps/reader/apps/mobile/docs/google-cloud-setup.md
Normal file
126
apps/reader/apps/mobile/docs/google-cloud-setup.md
Normal 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
|
||||
133
apps/reader/apps/mobile/docs/url-extraction-options.md
Normal file
133
apps/reader/apps/mobile/docs/url-extraction-options.md
Normal 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
|
||||
}
|
||||
```
|
||||
15
apps/reader/apps/mobile/eslint.config.js
Normal file
15
apps/reader/apps/mobile/eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
3
apps/reader/apps/mobile/global.css
Normal file
3
apps/reader/apps/mobile/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
416
apps/reader/apps/mobile/hooks/useAudio.ts
Normal file
416
apps/reader/apps/mobile/hooks/useAudio.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
113
apps/reader/apps/mobile/hooks/useAuth.ts
Normal file
113
apps/reader/apps/mobile/hooks/useAuth.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
177
apps/reader/apps/mobile/hooks/useTexts.ts
Normal file
177
apps/reader/apps/mobile/hooks/useTexts.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
179
apps/reader/apps/mobile/hooks/useTheme.ts
Normal file
179
apps/reader/apps/mobile/hooks/useTheme.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
10
apps/reader/apps/mobile/metro.config.js
Normal file
10
apps/reader/apps/mobile/metro.config.js
Normal 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' });
|
||||
3
apps/reader/apps/mobile/nativewind-env.d.ts
vendored
Normal file
3
apps/reader/apps/mobile/nativewind-env.d.ts
vendored
Normal 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.
|
||||
59
apps/reader/apps/mobile/package.json
Normal file
59
apps/reader/apps/mobile/package.json
Normal 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
|
||||
}
|
||||
10
apps/reader/apps/mobile/prettier.config.js
Normal file
10
apps/reader/apps/mobile/prettier.config.js
Normal 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'],
|
||||
};
|
||||
339
apps/reader/apps/mobile/services/audioService.ts
Normal file
339
apps/reader/apps/mobile/services/audioService.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
131
apps/reader/apps/mobile/services/urlExtractorService.ts
Normal file
131
apps/reader/apps/mobile/services/urlExtractorService.ts
Normal 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();
|
||||
84
apps/reader/apps/mobile/store/store.ts
Normal file
84
apps/reader/apps/mobile/store/store.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
1
apps/reader/apps/mobile/supabase/.temp/cli-latest
Normal file
1
apps/reader/apps/mobile/supabase/.temp/cli-latest
Normal file
|
|
@ -0,0 +1 @@
|
|||
v2.31.4
|
||||
1
apps/reader/apps/mobile/supabase/.temp/gotrue-version
Normal file
1
apps/reader/apps/mobile/supabase/.temp/gotrue-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
v2.177.0
|
||||
1
apps/reader/apps/mobile/supabase/.temp/pooler-url
Normal file
1
apps/reader/apps/mobile/supabase/.temp/pooler-url
Normal file
|
|
@ -0,0 +1 @@
|
|||
postgresql://postgres.tiecnhktvovcqsrnunko:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres
|
||||
1
apps/reader/apps/mobile/supabase/.temp/postgres-version
Normal file
1
apps/reader/apps/mobile/supabase/.temp/postgres-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
17.4.1.054
|
||||
1
apps/reader/apps/mobile/supabase/.temp/project-ref
Normal file
1
apps/reader/apps/mobile/supabase/.temp/project-ref
Normal file
|
|
@ -0,0 +1 @@
|
|||
tiecnhktvovcqsrnunko
|
||||
1
apps/reader/apps/mobile/supabase/.temp/rest-version
Normal file
1
apps/reader/apps/mobile/supabase/.temp/rest-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
v12.2.3
|
||||
1
apps/reader/apps/mobile/supabase/.temp/storage-version
Normal file
1
apps/reader/apps/mobile/supabase/.temp/storage-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
custom-metadata
|
||||
|
|
@ -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);
|
||||
}
|
||||
332
apps/reader/apps/mobile/supabase/functions/extract-url/index.ts
Normal file
332
apps/reader/apps/mobile/supabase/functions/extract-url/index.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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]
|
||||
);
|
||||
10
apps/reader/apps/mobile/tailwind.config.js
Normal file
10
apps/reader/apps/mobile/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
12
apps/reader/apps/mobile/tsconfig.json
Normal file
12
apps/reader/apps/mobile/tsconfig.json
Normal 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"]
|
||||
}
|
||||
79
apps/reader/apps/mobile/types/database.ts
Normal file
79
apps/reader/apps/mobile/types/database.ts
Normal 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;
|
||||
}
|
||||
60
apps/reader/apps/mobile/utils/audioMigration.ts
Normal file
60
apps/reader/apps/mobile/utils/audioMigration.ts
Normal 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()}`;
|
||||
}
|
||||
29
apps/reader/apps/mobile/utils/storage.ts
Normal file
29
apps/reader/apps/mobile/utils/storage.ts
Normal 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();
|
||||
14
apps/reader/apps/mobile/utils/supabase.ts
Normal file
14
apps/reader/apps/mobile/utils/supabase.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
14
package.json
14
package.json
|
|
@ -2,7 +2,7 @@
|
|||
"name": "manacore-monorepo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Manacore Monorepo containing maerchenzauber, manacore, manadeck, memoro, picture, uload, chat, nutriphi, news, wisekeep, quote, and bauntown",
|
||||
"description": "Manacore Monorepo containing maerchenzauber, manacore, manadeck, memoro, picture, uload, chat, nutriphi, news, wisekeep, quote, bauntown, presi, and reader",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
|
|
@ -90,6 +90,18 @@
|
|||
"bauntown:dev": "turbo run dev --filter=bauntown...",
|
||||
"dev:bauntown:landing": "pnpm --filter @bauntown/landing dev",
|
||||
|
||||
"presi:dev": "turbo run dev --filter=presi...",
|
||||
"dev:presi:mobile": "pnpm --filter @presi/mobile dev",
|
||||
"dev:presi:web": "pnpm --filter @presi/web dev",
|
||||
"dev:presi:backend": "pnpm --filter @presi/backend dev",
|
||||
"dev:presi:app": "turbo run dev --filter=@presi/web --filter=@presi/backend",
|
||||
"presi:db:push": "pnpm --filter @presi/backend db:push",
|
||||
"presi:db:studio": "pnpm --filter @presi/backend db:studio",
|
||||
"presi:db:seed": "pnpm --filter @presi/backend db:seed",
|
||||
|
||||
"reader:dev": "turbo run dev --filter=reader...",
|
||||
"dev:reader:mobile": "pnpm --filter @reader/mobile dev",
|
||||
|
||||
"docker:up": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis",
|
||||
"docker:up:auth": "docker compose -f docker-compose.dev.yml --env-file .env.development --profile auth up -d",
|
||||
"docker:up:chat": "docker compose -f docker-compose.dev.yml --env-file .env.development --profile chat up -d",
|
||||
|
|
|
|||
810
pnpm-lock.yaml
generated
810
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue