feat(reader): restore from archive, register in monorepo
- Move from apps-archived/ to apps/ - Add root package.json - Register in shared-branding (app icon, mana-apps, URL map) - Add to root CLAUDE.md project table - Expo/React Native TTS app preserved as-is (no rewrite needed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
|
@ -58,6 +58,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
|
|||
| **uload** | URL shortener & link management | Server, Web, Landing |
|
||||
| **news** | AI news reader & personal library | Server, Web, Landing |
|
||||
| **wisekeep** | AI transcription & wisdom library | Server, Web, Landing |
|
||||
| **reader** | Text-to-Speech with offline audio | Mobile |
|
||||
| **calc** | Calculator & converter | Web |
|
||||
| **playground** | LLM playground | Web |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,570 +0,0 @@
|
|||
# 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.
|
||||
562
apps/reader/apps/mobile/ReadMe/MinimalDatabase.md
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
# 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.
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
8
apps/reader/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@manacore/reader",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +117,9 @@ export const APP_ICONS = {
|
|||
uload: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ug" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#818cf8"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ug)"/><path d="M35 45a10 10 0 0 1 10-10h0a10 10 0 0 1 0 20h0M65 55a10 10 0 0 1-10 10h0a10 10 0 0 1 0-20h0M42 58l16-16" stroke="white" stroke-width="5" stroke-linecap="round" fill="none"/></svg>`
|
||||
),
|
||||
reader: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="rg" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f97316"/><stop offset="100%" style="stop-color:#fb923c"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#rg)"/><path d="M35 30h30a5 5 0 0 1 5 5v30a5 5 0 0 1-5 5H35a5 5 0 0 1-5-5V35a5 5 0 0 1 5-5z" stroke="white" stroke-width="3" fill="none"/><line x1="38" y1="42" x2="62" y2="42" stroke="white" stroke-width="2.5" stroke-linecap="round"/><line x1="38" y1="50" x2="58" y2="50" stroke="white" stroke-width="2.5" stroke-linecap="round"/><line x1="38" y1="58" x2="54" y2="58" stroke="white" stroke-width="2.5" stroke-linecap="round"/><circle cx="65" cy="65" r="10" fill="white" opacity="0.9"/><path d="M62 65l4.5 3v-6z" fill="#f97316"/></svg>`
|
||||
),
|
||||
news: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ng" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#10b981"/><stop offset="100%" style="stop-color:#34d399"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ng)"/><rect x="22" y="25" width="56" height="50" rx="4" stroke="white" stroke-width="4" fill="none"/><line x1="30" y1="38" x2="55" y2="38" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="48" x2="70" y2="48" stroke="white" stroke-width="3" stroke-linecap="round"/><line x1="30" y1="58" x2="65" y2="58" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
||||
),
|
||||
|
|
|
|||
|
|
@ -388,6 +388,22 @@ export const MANA_APPS: ManaApp[] = [
|
|||
comingSoon: false,
|
||||
status: 'development',
|
||||
},
|
||||
{
|
||||
id: 'reader',
|
||||
name: 'Reader',
|
||||
description: {
|
||||
de: 'Text-to-Speech mit Offline-Audio',
|
||||
en: 'Text-to-Speech with Offline Audio',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Texte in hochwertige Sprache umwandeln und offline anhören.',
|
||||
en: 'Convert text to high-quality speech and listen offline.',
|
||||
},
|
||||
icon: APP_ICONS.reader,
|
||||
color: '#f97316',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
},
|
||||
{
|
||||
id: 'news',
|
||||
name: 'News Hub',
|
||||
|
|
@ -513,6 +529,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
|
|||
citycorners: { dev: 'http://localhost:5196', prod: 'https://citycorners.mana.how' },
|
||||
taktik: { dev: 'http://localhost:5197', prod: 'https://taktik.mana.how' },
|
||||
uload: { dev: 'http://localhost:5173', prod: 'https://ulo.ad' },
|
||||
reader: { dev: 'exp://localhost:8081', prod: 'https://reader.mana.how' },
|
||||
news: { dev: 'http://localhost:5174', prod: 'https://news.mana.how' },
|
||||
calc: { dev: 'http://localhost:5198', prod: 'https://calc.mana.how' },
|
||||
};
|
||||
|
|
|
|||