diff --git a/apps/manacore/apps/landing/src/pages/apps/index.astro b/apps/manacore/apps/landing/src/pages/apps/index.astro
index 6daaaaee2..8ced37702 100644
--- a/apps/manacore/apps/landing/src/pages/apps/index.astro
+++ b/apps/manacore/apps/landing/src/pages/apps/index.astro
@@ -25,7 +25,6 @@ const sections: Section[] = [
{ name: 'Presi', icon: 'ph:presentation-chart-bold', tagline: 'KI Präsentationen', url: 'https://presi.mana.how' },
{ name: 'Questions', icon: 'ph:magnifying-glass-bold', tagline: 'KI Research', url: 'https://questions.mana.how' },
{ name: 'Context', icon: 'ph:file-text-bold', tagline: 'Dokument-Workspace', url: 'https://context.mana.how' },
- { name: 'Playground', icon: 'ph:flask-bold', tagline: 'LLM Playground', url: 'https://playground.mana.how' },
],
},
{
diff --git a/apps/playground/apps/web/.dockerignore b/apps/playground/apps/web/.dockerignore
deleted file mode 100644
index 15f48db44..000000000
--- a/apps/playground/apps/web/.dockerignore
+++ /dev/null
@@ -1,9 +0,0 @@
-node_modules
-.svelte-kit
-.git
-*.md
-.env*
-!.env.example
-dist
-coverage
-.DS_Store
diff --git a/apps/playground/apps/web/.gitignore b/apps/playground/apps/web/.gitignore
deleted file mode 100644
index d56da0f1e..000000000
--- a/apps/playground/apps/web/.gitignore
+++ /dev/null
@@ -1,7 +0,0 @@
-node_modules/
-.svelte-kit/
-.turbo/
-.env
-dist/
-build/
-*.log
diff --git a/apps/playground/apps/web/Dockerfile b/apps/playground/apps/web/Dockerfile
deleted file mode 100644
index 0b311ce56..000000000
--- a/apps/playground/apps/web/Dockerfile
+++ /dev/null
@@ -1,33 +0,0 @@
-# syntax=docker/dockerfile:1
-FROM sveltekit-base:local AS builder
-
-ARG PUBLIC_BACKEND_URL=http://mana-llm:3025
-ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001
-ARG PUBLIC_MANA_LLM_URL=http://mana-llm:3025
-ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
-ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
-ENV PUBLIC_MANA_LLM_URL=$PUBLIC_MANA_LLM_URL
-
-COPY apps/playground/apps/web ./apps/playground/apps/web
-
-RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
- pnpm install --no-frozen-lockfile --ignore-scripts
-
-WORKDIR /app/apps/playground/apps/web
-RUN pnpm exec svelte-kit sync
-RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
-
-FROM node:20-alpine AS production
-WORKDIR /app/apps/playground/apps/web
-COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
-COPY --from=builder /app/apps/playground/apps/web/node_modules ./node_modules
-COPY --from=builder /app/apps/playground/apps/web/build ./build
-COPY --from=builder /app/apps/playground/apps/web/package.json ./
-
-EXPOSE 5026
-ENV NODE_ENV=production PORT=5026 HOST=0.0.0.0
-
-HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:5026/health || exit 1
-
-CMD ["node", "build"]
diff --git a/apps/playground/apps/web/package.json b/apps/playground/apps/web/package.json
deleted file mode 100644
index f4cad948f..000000000
--- a/apps/playground/apps/web/package.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "name": "@playground/web",
- "private": true,
- "version": "0.1.0",
- "type": "module",
- "scripts": {
- "dev": "vite dev --port 5190",
- "dev:full": "vite dev --port 5190",
- "build": "vite build",
- "preview": "vite preview",
- "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
- },
- "devDependencies": {
- "@sveltejs/adapter-node": "^5.4.0",
- "@sveltejs/kit": "^2.47.1",
- "@sveltejs/vite-plugin-svelte": "^6.2.0",
- "@tailwindcss/vite": "^4.1.7",
- "svelte": "^5.41.0",
- "svelte-check": "^4.3.3",
- "tailwindcss": "^4.1.17",
- "typescript": "^5.9.3",
- "vite": "^7.1.7"
- },
- "dependencies": {
- "@manacore/shared-auth": "workspace:*",
- "@manacore/shared-auth-stores": "workspace:*",
- "@manacore/shared-auth-ui": "workspace:*",
- "@manacore/shared-branding": "workspace:*",
- "@manacore/shared-i18n": "workspace:*",
- "@manacore/shared-icons": "workspace:*",
- "marked": "^17.0.0",
- "svelte-i18n": "^4.0.1"
- }
-}
diff --git a/apps/playground/apps/web/src/app.css b/apps/playground/apps/web/src/app.css
deleted file mode 100644
index 0bf12813a..000000000
--- a/apps/playground/apps/web/src/app.css
+++ /dev/null
@@ -1,103 +0,0 @@
-@import 'tailwindcss';
-
-:root {
- --color-bg: #0f0f0f;
- --color-surface: #1a1a1a;
- --color-surface-hover: #252525;
- --color-border: #333;
- --color-text: #e5e5e5;
- --color-text-muted: #888;
- --color-primary: #3b82f6;
- --color-primary-hover: #2563eb;
- --color-success: #22c55e;
- --color-error: #ef4444;
- --color-warning: #f59e0b;
-}
-
-html {
- background-color: var(--color-bg);
- color: var(--color-text);
-}
-
-body {
- font-family:
- 'Inter',
- -apple-system,
- BlinkMacSystemFont,
- 'Segoe UI',
- Roboto,
- sans-serif;
-}
-
-/* Custom scrollbar */
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-::-webkit-scrollbar-track {
- background: var(--color-surface);
-}
-
-::-webkit-scrollbar-thumb {
- background: var(--color-border);
- border-radius: 4px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: #444;
-}
-
-/* Prose styles for markdown */
-.prose {
- line-height: 1.6;
-}
-
-.prose pre {
- background: var(--color-bg);
- padding: 1rem;
- border-radius: 0.5rem;
- overflow-x: auto;
- margin: 0.5rem 0;
-}
-
-.prose code {
- background: var(--color-bg);
- padding: 0.125rem 0.375rem;
- border-radius: 0.25rem;
- font-size: 0.875em;
-}
-
-.prose pre code {
- background: transparent;
- padding: 0;
-}
-
-.prose p {
- margin: 0.5rem 0;
-}
-
-.prose ul,
-.prose ol {
- margin: 0.5rem 0;
- padding-left: 1.5rem;
-}
-
-.prose li {
- margin: 0.25rem 0;
-}
-
-.prose h1,
-.prose h2,
-.prose h3 {
- margin-top: 1rem;
- margin-bottom: 0.5rem;
- font-weight: 600;
-}
-
-.prose blockquote {
- border-left: 3px solid var(--color-border);
- padding-left: 1rem;
- margin: 0.5rem 0;
- color: var(--color-text-muted);
-}
diff --git a/apps/playground/apps/web/src/app.d.ts b/apps/playground/apps/web/src/app.d.ts
deleted file mode 100644
index 3458eb1bd..000000000
--- a/apps/playground/apps/web/src/app.d.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-///
-
-declare global {
- namespace App {
- // interface Error {}
- // interface Locals {}
- // interface PageData {}
- // interface PageState {}
- // interface Platform {}
- }
-
- interface Window {
- __PUBLIC_MANA_CORE_AUTH_URL__?: string;
- __PUBLIC_MANA_LLM_URL__?: string;
- }
-}
-
-export {};
diff --git a/apps/playground/apps/web/src/app.html b/apps/playground/apps/web/src/app.html
deleted file mode 100644
index 77a5ff52c..000000000
--- a/apps/playground/apps/web/src/app.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
- %sveltekit.head%
-
-
- %sveltekit.body%
-
-
diff --git a/apps/playground/apps/web/src/hooks.server.ts b/apps/playground/apps/web/src/hooks.server.ts
deleted file mode 100644
index 908196b0c..000000000
--- a/apps/playground/apps/web/src/hooks.server.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Handle } from '@sveltejs/kit';
-
-const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
- process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
-const PUBLIC_MANA_LLM_URL_CLIENT =
- process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || '';
-
-export const handle: Handle = async ({ event, resolve }) => {
- return resolve(event, {
- transformPageChunk: ({ html }) => {
- const envScript = ``;
- return html.replace('', `${envScript}`);
- },
- });
-};
diff --git a/apps/playground/apps/web/src/lib/api/llm.ts b/apps/playground/apps/web/src/lib/api/llm.ts
deleted file mode 100644
index 5e15cb145..000000000
--- a/apps/playground/apps/web/src/lib/api/llm.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import type {
- ChatCompletionRequest,
- ChatCompletionResponse,
- HealthResponse,
- ModelsResponse,
- StreamChunk,
-} from '$lib/types';
-import { env } from '$env/dynamic/public';
-import { browser } from '$app/environment';
-
-function getApiBase(): string {
- if (browser) {
- return (
- (window as unknown as { __PUBLIC_MANA_LLM_URL__?: string }).__PUBLIC_MANA_LLM_URL__ ||
- env.PUBLIC_MANA_LLM_URL ||
- 'http://localhost:3025'
- );
- }
- return env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025';
-}
-
-export async function getHealth(): Promise {
- const response = await fetch(`${getApiBase()}/health`);
- if (!response.ok) {
- throw new Error(`Health check failed: ${response.statusText}`);
- }
- return response.json();
-}
-
-export async function getModels(): Promise {
- const response = await fetch(`${getApiBase()}/v1/models`);
- if (!response.ok) {
- throw new Error(`Failed to fetch models: ${response.statusText}`);
- }
- return response.json();
-}
-
-export async function sendCompletion(
- request: ChatCompletionRequest
-): Promise {
- const response = await fetch(`${getApiBase()}/v1/chat/completions`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ...request, stream: false }),
- });
- if (!response.ok) {
- throw new Error(`Completion failed: ${response.statusText}`);
- }
- return response.json();
-}
-
-export async function* streamCompletion(
- request: ChatCompletionRequest
-): AsyncGenerator {
- const response = await fetch(`${getApiBase()}/v1/chat/completions`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ ...request, stream: true }),
- });
-
- if (!response.ok) {
- const error = await response.text();
- throw new Error(`Completion failed: ${response.statusText} - ${error}`);
- }
-
- if (!response.body) {
- throw new Error('Response body is null');
- }
-
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = '';
-
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
- const lines = buffer.split('\n');
- buffer = lines.pop() || '';
-
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed || !trimmed.startsWith('data: ')) continue;
-
- const data = trimmed.slice(6);
- if (data === '[DONE]') return;
-
- try {
- const parsed: StreamChunk = JSON.parse(data);
- const content = parsed.choices[0]?.delta?.content;
- if (content) yield content;
- } catch {
- // Ignore malformed JSON chunks
- }
- }
- }
-
- // Process remaining buffer
- if (buffer.trim()) {
- const lines = buffer.split('\n');
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed || !trimmed.startsWith('data: ')) continue;
-
- const data = trimmed.slice(6);
- if (data === '[DONE]') return;
-
- try {
- const parsed: StreamChunk = JSON.parse(data);
- const content = parsed.choices[0]?.delta?.content;
- if (content) yield content;
- } catch {
- // Ignore malformed JSON chunks
- }
- }
- }
- } finally {
- reader.releaseLock();
- }
-}
diff --git a/apps/playground/apps/web/src/lib/components/chat/ChatInput.svelte b/apps/playground/apps/web/src/lib/components/chat/ChatInput.svelte
deleted file mode 100644
index 321f4d28b..000000000
--- a/apps/playground/apps/web/src/lib/components/chat/ChatInput.svelte
+++ /dev/null
@@ -1,80 +0,0 @@
-
-
-
-
-
-
- {#if chatStore.isStreaming}
-
- {:else}
-
- {/if}
-
-
diff --git a/apps/playground/apps/web/src/lib/components/chat/ComparisonMessageBubble.svelte b/apps/playground/apps/web/src/lib/components/chat/ComparisonMessageBubble.svelte
deleted file mode 100644
index a30a0c3de..000000000
--- a/apps/playground/apps/web/src/lib/components/chat/ComparisonMessageBubble.svelte
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
{message.userContent}
-
-
-
-
-
-
-
- Comparing {message.responses.length} models
-
-
-
-
-
-
- {#each message.responses as response (response.modelId)}
-
- {/each}
-
-
diff --git a/apps/playground/apps/web/src/lib/components/chat/MessageBubble.svelte b/apps/playground/apps/web/src/lib/components/chat/MessageBubble.svelte
deleted file mode 100644
index 03b2da0f7..000000000
--- a/apps/playground/apps/web/src/lib/components/chat/MessageBubble.svelte
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
- {#if message.role === 'assistant'}
-
- {#if message.isStreaming && message.content === ''}
-
-
-
-
-
- {:else}
-
- {@html renderedContent}
- {#if message.isStreaming}
-
- {/if}
- {/if}
-
- {:else}
-
{message.content}
- {/if}
-
-
- {formatTime(message.timestamp)}
- {#if message.model}
- ·
- {message.model.split('/').pop()}
- {/if}
-
-
-
diff --git a/apps/playground/apps/web/src/lib/components/chat/MessageList.svelte b/apps/playground/apps/web/src/lib/components/chat/MessageList.svelte
deleted file mode 100644
index f0da2620b..000000000
--- a/apps/playground/apps/web/src/lib/components/chat/MessageList.svelte
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
- {#if chatStore.messages.length === 0}
-
-
-
-
-
Start a conversation
-
- Select a model from the sidebar and send a message to begin testing the mana-llm service.
-
-
- {:else}
-
- {#each chatStore.messages as message (message.id)}
- {#if message.role === 'comparison'}
-
- {:else}
-
- {/if}
- {/each}
-
- {/if}
-
diff --git a/apps/playground/apps/web/src/lib/components/comparison/ComparisonResponseCard.svelte b/apps/playground/apps/web/src/lib/components/comparison/ComparisonResponseCard.svelte
deleted file mode 100644
index a65ed1bd4..000000000
--- a/apps/playground/apps/web/src/lib/components/comparison/ComparisonResponseCard.svelte
+++ /dev/null
@@ -1,62 +0,0 @@
-
-
-
-
-
-
- {modelName}
-
- {#if response.isStreaming}
-
- Streaming...
-
- {:else if response.error}
- Error
- {:else}
- Done
- {/if}
-
-
-
-
- {#if response.error}
-
{response.error}
- {:else}
-
{response.content}
- {#if response.isStreaming}
-
- {/if}
- {/if}
-
-
-
- {#if response.metrics && !response.isStreaming}
-
- {formatDuration(response.metrics.durationMs)}
- ~{response.metrics.completionTokens} tokens
- {#if response.metrics.tokensPerSecond > 0}
- {response.metrics.tokensPerSecond.toFixed(1)} t/s
- {/if}
-
- {/if}
-
diff --git a/apps/playground/apps/web/src/lib/components/comparison/ModelComparisonSelector.svelte b/apps/playground/apps/web/src/lib/components/comparison/ModelComparisonSelector.svelte
deleted file mode 100644
index 1b5e9f666..000000000
--- a/apps/playground/apps/web/src/lib/components/comparison/ModelComparisonSelector.svelte
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
Model Comparison
-
-
-
- {#if comparisonStore.comparisonMode}
-
-
-
- {#each filteredModels as model}
- {@const isSelected = comparisonStore.isModelSelected(model.id)}
- {@const isDisabled = !isSelected && !comparisonStore.canAddModel()}
-
- {/each}
-
-
-
- {comparisonStore.selectedModels.length}/{comparisonStore.maxModels} models selected
-
-
- {#if comparisonStore.selectedModels.length > 0}
-
- {/if}
- {/if}
-
diff --git a/apps/playground/apps/web/src/lib/components/comparison/ModelModalityFilter.svelte b/apps/playground/apps/web/src/lib/components/comparison/ModelModalityFilter.svelte
deleted file mode 100644
index 4fcbea22c..000000000
--- a/apps/playground/apps/web/src/lib/components/comparison/ModelModalityFilter.svelte
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
- {#each modelCounts as mod}
-
- {/each}
-
diff --git a/apps/playground/apps/web/src/lib/components/layout/Header.svelte b/apps/playground/apps/web/src/lib/components/layout/Header.svelte
deleted file mode 100644
index d1d867c0f..000000000
--- a/apps/playground/apps/web/src/lib/components/layout/Header.svelte
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
-
-
LLM Playground
-
mana-llm Service
-
-
-
-
-
- {#if healthStatus === 'loading'}
-
-
Checking...
- {:else if healthStatus === 'healthy'}
-
-
Healthy
- {#if healthDetails}
-
({healthDetails})
- {/if}
- {:else}
-
-
Offline
- {/if}
-
-
-
-
-
diff --git a/apps/playground/apps/web/src/lib/components/layout/Sidebar.svelte b/apps/playground/apps/web/src/lib/components/layout/Sidebar.svelte
deleted file mode 100644
index bc61b57bd..000000000
--- a/apps/playground/apps/web/src/lib/components/layout/Sidebar.svelte
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
diff --git a/apps/playground/apps/web/src/lib/components/settings/ModelSelector.svelte b/apps/playground/apps/web/src/lib/components/settings/ModelSelector.svelte
deleted file mode 100644
index 00c6f8f99..000000000
--- a/apps/playground/apps/web/src/lib/components/settings/ModelSelector.svelte
+++ /dev/null
@@ -1,66 +0,0 @@
-
-
-
-
- {#if modelsStore.loading}
-
- Loading models...
-
- {:else if modelsStore.error}
-
-
- {modelsStore.error}
-
-
-
- {:else}
-
-
- {#if selectedModelDescription()}
-
{selectedModelDescription()}
- {/if}
-
{modelsStore.models.length} models available
-
- {/if}
-
diff --git a/apps/playground/apps/web/src/lib/components/settings/ParameterPanel.svelte b/apps/playground/apps/web/src/lib/components/settings/ParameterPanel.svelte
deleted file mode 100644
index 2935ad576..000000000
--- a/apps/playground/apps/web/src/lib/components/settings/ParameterPanel.svelte
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
-
-
- {settingsStore.temperature.toFixed(2)}
-
-
-
- Precise
- Creative
-
-
-
-
-
-
- {settingsStore.maxTokens}
-
-
-
- 256
- 8192
-
-
-
-
-
-
- {settingsStore.topP.toFixed(2)}
-
-
-
- Focused
- Diverse
-
-
-
diff --git a/apps/playground/apps/web/src/lib/components/settings/SystemPromptEditor.svelte b/apps/playground/apps/web/src/lib/components/settings/SystemPromptEditor.svelte
deleted file mode 100644
index ad8d93ce5..000000000
--- a/apps/playground/apps/web/src/lib/components/settings/SystemPromptEditor.svelte
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
-
-
- {settingsStore.systemPrompt.length} characters
-
-
diff --git a/apps/playground/apps/web/src/lib/stores/auth.svelte.ts b/apps/playground/apps/web/src/lib/stores/auth.svelte.ts
deleted file mode 100644
index 5df932791..000000000
--- a/apps/playground/apps/web/src/lib/stores/auth.svelte.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Auth Store — uses centralized Mana auth factory.
- */
-
-import { createManaAuthStore } from '@manacore/shared-auth-stores';
-
-export const authStore = createManaAuthStore();
diff --git a/apps/playground/apps/web/src/lib/stores/chat.svelte.ts b/apps/playground/apps/web/src/lib/stores/chat.svelte.ts
deleted file mode 100644
index 2c00b69e9..000000000
--- a/apps/playground/apps/web/src/lib/stores/chat.svelte.ts
+++ /dev/null
@@ -1,205 +0,0 @@
-import type { AnyMessage, ChatMessage, ComparisonMessage, Message } from '$lib/types';
-import { streamCompletion } from '$lib/api/llm';
-import { settingsStore } from './settings.svelte';
-import { comparisonStore } from './comparison.svelte';
-
-function generateId(): string {
- return crypto.randomUUID();
-}
-
-function createChatStore() {
- let messages = $state([]);
- let isStreaming = $state(false);
- let abortController = $state(null);
-
- // Helper to extract conversation history for API calls
- function getConversationHistory(): Message[] {
- const history: Message[] = [];
- for (const msg of messages) {
- if (msg.role === 'user') {
- history.push({ role: 'user', content: (msg as ChatMessage).content });
- } else if (msg.role === 'assistant' && !(msg as ChatMessage).isStreaming) {
- history.push({ role: 'assistant', content: (msg as ChatMessage).content });
- }
- // Skip comparison messages in history for now
- }
- return history;
- }
-
- return {
- get messages() {
- return messages;
- },
- get isStreaming() {
- return isStreaming;
- },
-
- async sendMessage(content: string) {
- if (isStreaming || !content.trim()) return;
-
- // Add user message
- const userMessage: ChatMessage = {
- id: generateId(),
- role: 'user',
- content: content.trim(),
- timestamp: new Date(),
- };
- messages.push(userMessage);
-
- // Create assistant message placeholder
- const assistantMessage: ChatMessage = {
- id: generateId(),
- role: 'assistant',
- content: '',
- timestamp: new Date(),
- model: settingsStore.model,
- isStreaming: true,
- };
- messages.push(assistantMessage);
-
- // Build messages for API
- const apiMessages: Message[] = [];
-
- if (settingsStore.systemPrompt.trim()) {
- apiMessages.push({
- role: 'system',
- content: settingsStore.systemPrompt,
- });
- }
-
- for (const msg of messages) {
- if (msg.role === 'comparison') continue; // Skip comparison messages
- const chatMsg = msg as ChatMessage;
- if (chatMsg.role === 'user' || (chatMsg.role === 'assistant' && !chatMsg.isStreaming)) {
- apiMessages.push({
- role: chatMsg.role,
- content: chatMsg.content,
- });
- }
- }
-
- // Start streaming
- isStreaming = true;
- abortController = new AbortController();
-
- try {
- const stream = streamCompletion({
- model: settingsStore.model,
- messages: apiMessages,
- temperature: settingsStore.temperature,
- max_tokens: settingsStore.maxTokens,
- top_p: settingsStore.topP,
- stream: true,
- });
-
- for await (const chunk of stream) {
- // Find and update the assistant message
- const idx = messages.findIndex((m) => m.id === assistantMessage.id);
- if (idx !== -1) {
- (messages[idx] as ChatMessage).content += chunk;
- }
- }
-
- // Mark streaming complete
- const idx = messages.findIndex((m) => m.id === assistantMessage.id);
- if (idx !== -1) {
- (messages[idx] as ChatMessage).isStreaming = false;
- (messages[idx] as ChatMessage).timestamp = new Date();
- }
- } catch (error) {
- // Update message with error
- const idx = messages.findIndex((m) => m.id === assistantMessage.id);
- if (idx !== -1) {
- (messages[idx] as ChatMessage).content =
- `Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
- (messages[idx] as ChatMessage).isStreaming = false;
- }
- } finally {
- isStreaming = false;
- abortController = null;
- }
- },
-
- async sendComparisonMessage(content: string) {
- if (isStreaming || !content.trim()) return;
- if (comparisonStore.selectedModels.length < 2) return;
-
- const comparisonMsg: ComparisonMessage = {
- id: generateId(),
- role: 'comparison',
- userContent: content.trim(),
- responses: [],
- timestamp: new Date(),
- };
-
- messages = [...messages, comparisonMsg];
- isStreaming = true;
-
- try {
- const history = getConversationHistory();
- await comparisonStore.compareModels(
- content.trim(),
- comparisonStore.selectedModels,
- history,
- (responses) => {
- const idx = messages.findIndex((m) => m.id === comparisonMsg.id);
- if (idx !== -1) {
- (messages[idx] as ComparisonMessage).responses = responses;
- messages = [...messages];
- }
- }
- );
- } finally {
- isStreaming = false;
- }
- },
-
- stopStreaming() {
- if (abortController) {
- abortController.abort();
- }
- },
-
- clearMessages() {
- messages = [];
- isStreaming = false;
- },
-
- exportMessages(): string {
- return JSON.stringify(
- {
- exported: new Date().toISOString(),
- settings: {
- model: settingsStore.model,
- temperature: settingsStore.temperature,
- maxTokens: settingsStore.maxTokens,
- topP: settingsStore.topP,
- systemPrompt: settingsStore.systemPrompt,
- },
- messages: messages.map((m) => {
- if (m.role === 'comparison') {
- const compMsg = m as ComparisonMessage;
- return {
- role: 'comparison',
- userContent: compMsg.userContent,
- responses: compMsg.responses,
- timestamp: compMsg.timestamp,
- };
- }
- const chatMsg = m as ChatMessage;
- return {
- role: chatMsg.role,
- content: chatMsg.content,
- timestamp: chatMsg.timestamp,
- model: chatMsg.model,
- };
- }),
- },
- null,
- 2
- );
- },
- };
-}
-
-export const chatStore = createChatStore();
diff --git a/apps/playground/apps/web/src/lib/stores/comparison.svelte.ts b/apps/playground/apps/web/src/lib/stores/comparison.svelte.ts
deleted file mode 100644
index caadb6b45..000000000
--- a/apps/playground/apps/web/src/lib/stores/comparison.svelte.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { streamCompletion } from '$lib/api/llm';
-import type { ComparisonResponse, ChatCompletionRequest, Message } from '$lib/types';
-import { settingsStore } from './settings.svelte';
-
-function createComparisonStore() {
- let comparisonMode = $state(false);
- let selectedModels = $state([]);
- const maxModels = 4;
-
- return {
- get comparisonMode() {
- return comparisonMode;
- },
- get selectedModels() {
- return selectedModels;
- },
- get maxModels() {
- return maxModels;
- },
-
- toggleComparisonMode() {
- comparisonMode = !comparisonMode;
- if (!comparisonMode) {
- selectedModels = [];
- }
- },
-
- setComparisonMode(value: boolean) {
- comparisonMode = value;
- if (!value) {
- selectedModels = [];
- }
- },
-
- toggleModel(modelId: string) {
- if (selectedModels.includes(modelId)) {
- selectedModels = selectedModels.filter((m) => m !== modelId);
- } else if (selectedModels.length < maxModels) {
- selectedModels = [...selectedModels, modelId];
- }
- },
-
- isModelSelected(modelId: string): boolean {
- return selectedModels.includes(modelId);
- },
-
- clearSelection() {
- selectedModels = [];
- },
-
- canAddModel(): boolean {
- return selectedModels.length < maxModels;
- },
-
- async compareModels(
- content: string,
- models: string[],
- conversationHistory: Message[],
- onUpdate: (responses: ComparisonResponse[]) => void
- ): Promise {
- const responses: ComparisonResponse[] = models.map((modelId) => ({
- modelId,
- content: '',
- isStreaming: true,
- startTime: Date.now(),
- }));
-
- onUpdate([...responses]);
-
- // Build base messages including history
- const baseMessages: Message[] = [];
-
- if (settingsStore.systemPrompt.trim()) {
- baseMessages.push({
- role: 'system',
- content: settingsStore.systemPrompt,
- });
- }
-
- // Add conversation history
- baseMessages.push(...conversationHistory);
-
- // Add current user message
- baseMessages.push({
- role: 'user',
- content,
- });
-
- // Start parallel streams for all models
- const streamPromises = models.map(async (modelId, index) => {
- const request: ChatCompletionRequest = {
- model: modelId,
- messages: baseMessages,
- temperature: settingsStore.temperature,
- max_tokens: settingsStore.maxTokens,
- top_p: settingsStore.topP,
- stream: true,
- };
-
- try {
- let tokenCount = 0;
- for await (const chunk of streamCompletion(request)) {
- responses[index].content += chunk;
- tokenCount++;
- onUpdate([...responses]);
- }
-
- responses[index].isStreaming = false;
- responses[index].endTime = Date.now();
-
- const durationMs = responses[index].endTime! - responses[index].startTime;
- // Estimate tokens (rough approximation based on whitespace-split words)
- const estimatedTokens = responses[index].content.split(/\s+/).length;
-
- responses[index].metrics = {
- promptTokens: 0, // Not available from stream
- completionTokens: estimatedTokens,
- totalTokens: estimatedTokens,
- durationMs,
- tokensPerSecond: durationMs > 0 ? (estimatedTokens / durationMs) * 1000 : 0,
- };
- } catch (error) {
- responses[index].isStreaming = false;
- responses[index].error = error instanceof Error ? error.message : 'Unknown error';
- responses[index].endTime = Date.now();
- }
-
- onUpdate([...responses]);
- });
-
- await Promise.all(streamPromises);
- return responses;
- },
- };
-}
-
-export const comparisonStore = createComparisonStore();
diff --git a/apps/playground/apps/web/src/lib/stores/models.svelte.ts b/apps/playground/apps/web/src/lib/stores/models.svelte.ts
deleted file mode 100644
index b252c28c0..000000000
--- a/apps/playground/apps/web/src/lib/stores/models.svelte.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-import type { Model, ModelWithModality, Modality, Provider } from '$lib/types';
-import { getModels } from '$lib/api/llm';
-
-/**
- * Model metadata configuration
- * Add new models here when installing them on the server
- * See: docs/OLLAMA_MODELS.md for instructions
- */
-export const MODEL_METADATA: Record = {
- // Text Models - General Purpose
- 'gemma3:4b': {
- description: 'Fast general-purpose model (~53 t/s)',
- modality: 'text',
- },
- 'gemma3:12b': {
- description: 'Balanced quality & speed (~30 t/s)',
- modality: 'text',
- },
- 'gemma3:27b': {
- description: 'Best quality, slower (~15 t/s)',
- modality: 'text',
- },
- 'phi3.5:latest': {
- description: 'Microsoft Phi-3.5 - compact & efficient',
- modality: 'text',
- },
- 'ministral-3:3b': {
- description: 'Mistral Mini - fast for simple tasks',
- modality: 'text',
- },
-
- // Vision Models
- 'llava:7b': {
- description: 'Image understanding & description',
- modality: 'vision',
- },
- 'qwen3-vl:4b': {
- description: 'Qwen Vision-Language model',
- modality: 'vision',
- },
- 'deepseek-ocr:latest': {
- description: 'OCR & document understanding',
- modality: 'vision',
- },
-
- // Code Models
- 'qwen2.5-coder:7b': {
- description: 'Code generation & completion (7B)',
- modality: 'code',
- },
- 'qwen2.5-coder:14b': {
- description: 'Advanced code generation (14B)',
- modality: 'code',
- },
-};
-
-/**
- * Detect modality from model ID
- * First checks MODEL_METADATA, then falls back to pattern matching
- */
-function detectModality(modelId: string): Modality {
- const id = modelId.toLowerCase();
-
- // Extract model name from provider prefix (e.g., "ollama/gemma3:4b" -> "gemma3:4b")
- const modelName = id.includes('/') ? id.split('/').slice(1).join('/') : id;
-
- // Check metadata first
- if (MODEL_METADATA[modelName]) {
- return MODEL_METADATA[modelName].modality;
- }
-
- // Vision models (pattern matching fallback)
- if (
- id.includes('llava') ||
- id.includes('vision') ||
- id.includes('-vl') ||
- id.includes('ocr') ||
- id.includes('moondream')
- ) {
- return 'vision';
- }
-
- // Code models (pattern matching fallback)
- if (id.includes('coder') || id.includes('codellama') || id.includes('starcoder')) {
- return 'code';
- }
-
- // Default to text
- return 'text';
-}
-
-/**
- * Get model description from metadata
- */
-function getModelDescription(modelId: string): string | undefined {
- const modelName = modelId.includes('/') ? modelId.split('/').slice(1).join('/') : modelId;
- return MODEL_METADATA[modelName]?.description;
-}
-
-interface GroupedModels {
- provider: Provider;
- label: string;
- models: Model[];
-}
-
-function createModelsStore() {
- let models = $state([]);
- let loading = $state(false);
- let error = $state(null);
-
- // Models with modality information for comparison
- const modelsWithModality = $derived(
- models.map((model) => ({
- ...model,
- modality: detectModality(model.id),
- description: getModelDescription(model.id),
- }))
- );
-
- const groupedModels = $derived.by(() => {
- const groups: Record = {
- ollama: [],
- openrouter: [],
- groq: [],
- together: [],
- };
-
- for (const model of models) {
- const id = model.id;
- if (id.startsWith('ollama/')) {
- groups.ollama.push(model);
- } else if (id.startsWith('openrouter/')) {
- groups.openrouter.push(model);
- } else if (id.startsWith('groq/')) {
- groups.groq.push(model);
- } else if (id.startsWith('together/')) {
- groups.together.push(model);
- }
- }
-
- const result: GroupedModels[] = [];
- const labels: Record = {
- ollama: 'Ollama (Local)',
- openrouter: 'OpenRouter',
- groq: 'Groq',
- together: 'Together AI',
- };
-
- for (const [provider, providerModels] of Object.entries(groups)) {
- if (providerModels.length > 0) {
- result.push({
- provider: provider as Provider,
- label: labels[provider as Provider],
- models: providerModels.sort((a, b) => a.id.localeCompare(b.id)),
- });
- }
- }
-
- return result;
- });
-
- return {
- get models() {
- return models;
- },
- get loading() {
- return loading;
- },
- get error() {
- return error;
- },
- get groupedModels() {
- return groupedModels;
- },
- get modelsWithModality() {
- return modelsWithModality;
- },
-
- async loadModels() {
- loading = true;
- error = null;
-
- try {
- const response = await getModels();
- models = response.data;
- } catch (e) {
- error = e instanceof Error ? e.message : 'Failed to load models';
- models = [];
- } finally {
- loading = false;
- }
- },
- };
-}
-
-export const modelsStore = createModelsStore();
diff --git a/apps/playground/apps/web/src/lib/stores/settings.svelte.ts b/apps/playground/apps/web/src/lib/stores/settings.svelte.ts
deleted file mode 100644
index 8d02af78e..000000000
--- a/apps/playground/apps/web/src/lib/stores/settings.svelte.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import type { Settings } from '$lib/types';
-import { browser } from '$app/environment';
-
-const STORAGE_KEY = 'llm-playground-settings';
-
-const defaultSettings: Settings = {
- model: 'ollama/gemma3:4b',
- temperature: 0.7,
- maxTokens: 2048,
- topP: 1.0,
- systemPrompt: 'You are a helpful AI assistant.',
-};
-
-function loadSettings(): Settings {
- if (!browser) return defaultSettings;
-
- try {
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored) {
- return { ...defaultSettings, ...JSON.parse(stored) };
- }
- } catch {
- // Ignore parse errors
- }
- return defaultSettings;
-}
-
-function saveSettings(settings: Settings): void {
- if (!browser) return;
- localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
-}
-
-function createSettingsStore() {
- let settings = $state(loadSettings());
-
- return {
- get model() {
- return settings.model;
- },
- set model(value: string) {
- settings.model = value;
- saveSettings(settings);
- },
-
- get temperature() {
- return settings.temperature;
- },
- set temperature(value: number) {
- settings.temperature = value;
- saveSettings(settings);
- },
-
- get maxTokens() {
- return settings.maxTokens;
- },
- set maxTokens(value: number) {
- settings.maxTokens = value;
- saveSettings(settings);
- },
-
- get topP() {
- return settings.topP;
- },
- set topP(value: number) {
- settings.topP = value;
- saveSettings(settings);
- },
-
- get systemPrompt() {
- return settings.systemPrompt;
- },
- set systemPrompt(value: string) {
- settings.systemPrompt = value;
- saveSettings(settings);
- },
-
- reset() {
- settings = { ...defaultSettings };
- saveSettings(settings);
- },
- };
-}
-
-export const settingsStore = createSettingsStore();
diff --git a/apps/playground/apps/web/src/lib/types/index.ts b/apps/playground/apps/web/src/lib/types/index.ts
deleted file mode 100644
index a0905f76c..000000000
--- a/apps/playground/apps/web/src/lib/types/index.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-export interface Message {
- role: 'system' | 'user' | 'assistant';
- content: string;
-}
-
-export interface ChatCompletionRequest {
- model: string;
- messages: Message[];
- temperature?: number;
- max_tokens?: number;
- top_p?: number;
- stream?: boolean;
-}
-
-export interface ChatCompletionChoice {
- index: number;
- message: Message;
- finish_reason: string | null;
-}
-
-export interface ChatCompletionResponse {
- id: string;
- object: string;
- created: number;
- model: string;
- choices: ChatCompletionChoice[];
- usage?: {
- prompt_tokens: number;
- completion_tokens: number;
- total_tokens: number;
- };
-}
-
-export interface StreamDelta {
- role?: string;
- content?: string;
-}
-
-export interface StreamChoice {
- index: number;
- delta: StreamDelta;
- finish_reason: string | null;
-}
-
-export interface StreamChunk {
- id: string;
- object: string;
- created: number;
- model: string;
- choices: StreamChoice[];
-}
-
-export interface Model {
- id: string;
- object: string;
- created: number;
- owned_by: string;
-}
-
-export interface ModelsResponse {
- object: string;
- data: Model[];
-}
-
-export interface HealthResponse {
- status: string;
- timestamp: string;
- version?: string;
-}
-
-export interface ChatMessage {
- id: string;
- role: 'user' | 'assistant';
- content: string;
- timestamp: Date;
- model?: string;
- isStreaming?: boolean;
-}
-
-export interface Settings {
- model: string;
- temperature: number;
- maxTokens: number;
- topP: number;
- systemPrompt: string;
-}
-
-export type Provider = 'ollama' | 'openrouter' | 'groq' | 'together';
-
-// Modality types for model comparison
-export type Modality = 'text' | 'vision' | 'code';
-
-export interface ModelWithModality extends Model {
- modality: Modality;
- description?: string;
-}
-
-// Comparison response from a single model
-export interface ComparisonResponse {
- modelId: string;
- content: string;
- isStreaming: boolean;
- startTime: number;
- endTime?: number;
- metrics?: {
- promptTokens: number;
- completionTokens: number;
- totalTokens: number;
- durationMs: number;
- tokensPerSecond: number;
- };
- error?: string;
-}
-
-// Comparison message containing multiple model responses
-export interface ComparisonMessage {
- id: string;
- role: 'comparison';
- userContent: string;
- responses: ComparisonResponse[];
- timestamp: Date;
-}
-
-// Union type for all message types
-export type AnyMessage = ChatMessage | ComparisonMessage;
diff --git a/apps/playground/apps/web/src/routes/(auth)/+layout.svelte b/apps/playground/apps/web/src/routes/(auth)/+layout.svelte
deleted file mode 100644
index fd5e639d7..000000000
--- a/apps/playground/apps/web/src/routes/(auth)/+layout.svelte
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-{@render children()}
diff --git a/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte b/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte
deleted file mode 100644
index 1dbd5f445..000000000
--- a/apps/playground/apps/web/src/routes/(auth)/login/+page.svelte
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
- {translations.title} | LLM Playground
-
-
- authStore.signInWithPasskey()}
- onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)}
- onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)}
- onSendMagicLink={(email) => authStore.sendMagicLink(email)}
- {goto}
- successRedirect={redirectTo}
- registerPath="/register"
- forgotPasswordPath="/forgot-password"
- lightBackground="#ecfeff"
- darkBackground="#083344"
- {translations}
- {verified}
- {initialEmail}
-/>
diff --git a/apps/playground/apps/web/src/routes/(auth)/register/+page.svelte b/apps/playground/apps/web/src/routes/(auth)/register/+page.svelte
deleted file mode 100644
index 4cfec7ce2..000000000
--- a/apps/playground/apps/web/src/routes/(auth)/register/+page.svelte
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
- {translations.title} | LLM Playground
-
-
-
diff --git a/apps/playground/apps/web/src/routes/(protected)/+layout.svelte b/apps/playground/apps/web/src/routes/(protected)/+layout.svelte
deleted file mode 100644
index d98d62268..000000000
--- a/apps/playground/apps/web/src/routes/(protected)/+layout.svelte
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-{#if isChecking}
-
-{:else}
- {@render children()}
-{/if}
diff --git a/apps/playground/apps/web/src/routes/(protected)/+page.svelte b/apps/playground/apps/web/src/routes/(protected)/+page.svelte
deleted file mode 100644
index fc758c74f..000000000
--- a/apps/playground/apps/web/src/routes/(protected)/+page.svelte
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
- LLM Playground | mana-llm
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/playground/apps/web/src/routes/+layout.svelte b/apps/playground/apps/web/src/routes/+layout.svelte
deleted file mode 100644
index 0b8a3f4c7..000000000
--- a/apps/playground/apps/web/src/routes/+layout.svelte
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
- {@render children()}
-
diff --git a/apps/playground/apps/web/src/routes/health/+server.ts b/apps/playground/apps/web/src/routes/health/+server.ts
deleted file mode 100644
index 991cbfb8f..000000000
--- a/apps/playground/apps/web/src/routes/health/+server.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { json } from '@sveltejs/kit';
-import type { RequestHandler } from './$types';
-
-export const GET: RequestHandler = async () => {
- return json({
- status: 'healthy',
- service: 'llm-playground',
- timestamp: new Date().toISOString(),
- });
-};
diff --git a/apps/playground/apps/web/static/favicon.png b/apps/playground/apps/web/static/favicon.png
deleted file mode 100644
index d831d66d7..000000000
Binary files a/apps/playground/apps/web/static/favicon.png and /dev/null differ
diff --git a/apps/playground/apps/web/svelte.config.js b/apps/playground/apps/web/svelte.config.js
deleted file mode 100644
index fc92816a8..000000000
--- a/apps/playground/apps/web/svelte.config.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import adapter from '@sveltejs/adapter-node';
-import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
-
-/** @type {import('@sveltejs/kit').Config} */
-const config = {
- preprocess: vitePreprocess(),
- kit: {
- adapter: adapter(),
- },
-};
-
-export default config;
diff --git a/apps/playground/apps/web/tsconfig.json b/apps/playground/apps/web/tsconfig.json
deleted file mode 100644
index a8f10c8e3..000000000
--- a/apps/playground/apps/web/tsconfig.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "extends": "./.svelte-kit/tsconfig.json",
- "compilerOptions": {
- "allowJs": true,
- "checkJs": true,
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true,
- "resolveJsonModule": true,
- "skipLibCheck": true,
- "sourceMap": true,
- "strict": true,
- "moduleResolution": "bundler"
- }
-}
diff --git a/apps/playground/apps/web/vite.config.ts b/apps/playground/apps/web/vite.config.ts
deleted file mode 100644
index 138c229a6..000000000
--- a/apps/playground/apps/web/vite.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import tailwindcss from '@tailwindcss/vite';
-import { sveltekit } from '@sveltejs/kit/vite';
-import { defineConfig } from 'vite';
-
-export default defineConfig({
- plugins: [tailwindcss(), sveltekit()],
-});
diff --git a/apps/playground/package.json b/apps/playground/package.json
deleted file mode 100644
index 9a4a81652..000000000
--- a/apps/playground/package.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "name": "playground",
- "private": true,
- "scripts": {
- "dev": "turbo run dev"
- }
-}
diff --git a/apps/reader/.gitignore b/apps/reader/.gitignore
deleted file mode 100644
index f5f1c697b..000000000
--- a/apps/reader/.gitignore
+++ /dev/null
@@ -1,40 +0,0 @@
-# Dependencies
-node_modules/
-
-# Build outputs
-dist/
-web-build/
-
-# Expo
-.expo/
-expo-env.d.ts
-
-# React Native (generated via expo prebuild)
-ios/
-android/
-
-# Environment
-.env
-.env.local
-.env.production
-
-# Debug
-npm-debug.*
-*.log
-
-# Certificates & Keys
-*.jks
-*.p8
-*.p12
-*.key
-*.mobileprovision
-*.orig.*
-
-# Metro
-.metro-health-check*
-
-# IDE
-.idea/
-
-# OS
-.DS_Store
diff --git a/apps/reader/CLAUDE.md b/apps/reader/CLAUDE.md
deleted file mode 100644
index c2e841e39..000000000
--- a/apps/reader/CLAUDE.md
+++ /dev/null
@@ -1,149 +0,0 @@
-# CLAUDE.md - Reader
-
-This file provides guidance to Claude Code when working with the Reader project.
-
-## Project Overview
-
-Reader is a Text-to-Speech React Native application built with Expo that converts text to high-quality audio using Google Chirp voices. It stores audio locally for offline playback and syncs data across devices via Supabase.
-
-## Architecture
-
-```
-apps/reader/
-├── apps/
-│ └── mobile/ # Expo React Native App (@reader/mobile)
-│ ├── app/ # Expo Router navigation
-│ │ ├── (tabs)/ # Tab navigation screens
-│ │ ├── (auth)/ # Auth flow routes
-│ │ └── _layout.tsx
-│ ├── components/ # Reusable UI components
-│ ├── hooks/ # Custom React hooks
-│ ├── services/ # Business logic services
-│ ├── store/ # Zustand state management
-│ ├── types/ # TypeScript types
-│ ├── utils/ # Utilities (Supabase client, etc.)
-│ ├── assets/ # Images, fonts
-│ └── package.json # @reader/mobile
-├── packages/ # For future shared code
-├── CLAUDE.md # This file
-└── .gitignore
-```
-
-## Development Commands
-
-```bash
-# From monorepo root
-pnpm install
-
-# Start Reader mobile app
-pnpm reader:dev
-# Or directly
-pnpm dev:reader:mobile
-
-# From apps/reader/apps/mobile/
-pnpm dev # Start Expo dev server
-pnpm ios # Run on iOS simulator
-pnpm android # Run on Android emulator
-pnpm web # Run on web
-
-# Code quality
-pnpm lint # Run ESLint
-pnpm format # Format with Prettier
-
-# Build for production
-pnpm build:preview # Preview build
-pnpm build:prod # Production build
-```
-
-## Tech Stack
-
-| Component | Technology |
-| ---------- | --------------------------------- |
-| Framework | React Native 0.79.5 + Expo SDK 53 |
-| Navigation | Expo Router v5 (file-based) |
-| Styling | NativeWind (Tailwind CSS for RN) |
-| State | Zustand |
-| Backend | Supabase (PostgreSQL + Auth) |
-| Language | TypeScript |
-
-## Database Design
-
-Single `texts` table with JSONB field for flexibility:
-
-- Stores texts, metadata, tags, and reading progress
-- Audio files stored locally, paths tracked in DB
-- Designed for future expansion without migrations
-
-See `apps/mobile/ReadMe/MinimalDatabase.md` for details.
-
-## Key Implementation Patterns
-
-### Navigation (Expo Router)
-
-```tsx
-// File-based routing in apps/mobile/app/
-// (tabs)/ - Tab navigation screens
-// (auth)/ - Auth flow routes
-```
-
-### Styling (NativeWind)
-
-```tsx
-
- Hello
-
-```
-
-### State Management (Zustand)
-
-```tsx
-import { useStore } from '~/store/store';
-const { state, actions } = useStore();
-```
-
-### Supabase Client
-
-```tsx
-// Client configured in apps/mobile/utils/supabase.ts
-import { supabase } from '~/utils/supabase';
-```
-
-### Path Alias
-
-Use `~/*` for absolute imports from mobile root:
-
-```tsx
-import { Button } from '~/components/Button';
-```
-
-## Environment Variables
-
-Create `apps/reader/apps/mobile/.env`:
-
-```bash
-EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
-EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
-```
-
-## Current Implementation Status
-
-- [x] Expo Router setup with tab navigation
-- [x] Supabase integration
-- [x] Zustand store (user state, settings, audio player)
-- [x] NativeWind styling
-- [x] User authentication (Login, Register, Forgot Password)
-- [x] Text management UI (List, Add, View, Delete)
-- [x] Settings screen
-- [x] Text-to-Speech with Google Cloud TTS
-- [x] Audio player with progress tracking
-- [x] Offline audio storage (Expo FileSystem)
-- [x] Tag system with filtering
-- [x] Supabase Edge Functions for audio generation
-- [x] Audio chunk system for large texts
-- [x] Local audio caching
-
-## Detailed Documentation
-
-- `apps/mobile/ReadMe/ProjectOverview.md` - Project vision (German)
-- `apps/mobile/ReadMe/MinimalDatabase.md` - Database design
-- `apps/mobile/docs/` - Additional documentation
diff --git a/apps/reader/apps/mobile/CONTEXT_MENU_SOLUTION.md b/apps/reader/apps/mobile/CONTEXT_MENU_SOLUTION.md
deleted file mode 100644
index df05d5831..000000000
--- a/apps/reader/apps/mobile/CONTEXT_MENU_SOLUTION.md
+++ /dev/null
@@ -1,64 +0,0 @@
-# Context Menu Solution
-
-I've fixed the "View config not found for component 'ContextMenu'" error by replacing the native `react-native-context-menu-view` with a cross-platform solution that works with Expo Go.
-
-## What was changed:
-
-1. **Removed the native dependency**: Uninstalled `react-native-context-menu-view` which requires native code and doesn't work with Expo Go.
-
-2. **Created two alternative solutions**:
-
-### Option 1: ActionMenu (Currently Active)
-
-- Located in `/components/ActionMenu.tsx`
-- Uses native ActionSheetIOS on iOS for a truly native experience
-- Custom modal implementation for Android that slides up from bottom
-- Triggered by long press on list items
-
-### Option 2: ContextMenu (Alternative)
-
-- Located in `/components/ContextMenu.tsx`
-- Custom modal-based context menu that appears near the pressed item
-- Works on both platforms with consistent behavior
-- More traditional context menu appearance
-
-## How it works:
-
-The ActionMenu component wraps your list items and provides a long-press gesture handler. When activated:
-
-- On iOS: Shows native ActionSheetIOS
-- On Android: Shows a custom bottom sheet modal
-
-## Usage:
-
-```tsx
- {
- // Handle selection
- }}
->
-
-
-```
-
-## Benefits:
-
-- Works with Expo Go (no development build required)
-- Native feel on iOS
-- Consistent experience across platforms
-- No native dependencies
-
-## If you need a development build:
-
-If you prefer to use the native context menu and are willing to use a development build:
-
-1. Re-install `react-native-context-menu-view`
-2. Run `npx expo prebuild`
-3. Run `npx expo run:ios` or `npx expo run:android`
-
-The current solution allows you to continue using Expo Go for development while providing a good user experience.
diff --git a/apps/reader/apps/mobile/ReadMe/AudioPlayerImprovements.md b/apps/reader/apps/mobile/ReadMe/AudioPlayerImprovements.md
deleted file mode 100644
index 9593d4315..000000000
--- a/apps/reader/apps/mobile/ReadMe/AudioPlayerImprovements.md
+++ /dev/null
@@ -1,78 +0,0 @@
-⏺ AudioPlayer Verbesserungsplan
-
-1. Geschwindigkeitssteuerung
-
-- Playback-Speed Kontrolle
- (0.5x, 0.75x, 1x, 1.25x, 1.5x,
- 2x)
-- Geschwindigkeit bleibt für
- alle Audios gespeichert
-- Kleiner Button neben den
- Controls
-
-2. Visuelle Verbesserungen
-
-- Waveform-Visualisierung
- während der Wiedergabe
-- Bessere Progress-Bar mit
- Touch-Gesten zum Scrubben
-- Pulsierender Play-Button
- während des Ladens
-
-3. Erweiterte Navigation
-
-- Kapitel-Support (bei
- längeren Texten)
-- Lesezeichen setzen während
- der Wiedergabe
-- Zu bestimmten Zeitstempeln
- springen
-
-4. Sleep Timer
-
-- Timer zum automatischen
- Stoppen (15, 30, 45, 60 Min)
-- Fade-Out am Ende
-- Visual Countdown
-
-5. Kontinuierliche Wiedergabe
-
-- Automatisch nächsten Text
- abspielen
-- Queue-System für mehrere
- Texte
-- Shuffle-Modus
-
-6. Mini-Player
-
-- Kompakter Player am unteren
- Bildschirmrand
-- Bleibt beim Navigieren
- sichtbar
-- Swipe-to-dismiss
-
-7. Offline-Optimierung
-
-- Download-Button für lokale
- Speicherung
-- Download-Progress anzeigen
-- Cache-Management UI
-
-8. Statistiken & History
-
-- Listening History anzeigen
-- Fortschritt pro Text tracken
-- Gesamte Hörzeit
-
-9. Accessibility
-
-- VoiceOver Support verbessern
-- Größere Touch-Targets
-- Keyboard-Shortcuts (iPad)
-
-10. Performance
-
-- Preloading des nächsten
- Chunks
-- Smooth Chunk-Übergänge
-- Background Audio optimieren
diff --git a/apps/reader/apps/mobile/ReadMe/ExpoUI.md b/apps/reader/apps/mobile/ReadMe/ExpoUI.md
deleted file mode 100644
index 8d551bba9..000000000
--- a/apps/reader/apps/mobile/ReadMe/ExpoUI.md
+++ /dev/null
@@ -1,226 +0,0 @@
-Expo UI
-
-A set of components that allow you to build UIs directly with SwiftUI and Jetpack Compose from React.
-
-Bundled version:
-~0.1.1-alpha.10
-This library is currently in alpha and will frequently experience breaking changes. It is not available in the Expo Go app – use development builds to try it out.
-@expo/ui is a set of native input components that allows you to build fully native interfaces with SwiftUI and Jetpack Compose. It aims to provide the commonly used features and components that a typical app will need.
-
-Installation
-Terminal
-
-Copy
-
-npx expo install @expo/ui
-If you are installing this in an existing React Native app, make sure to install expo in your project.
-
-Swift UI examples
-BottomSheet
-
-iOS
-
-Code
-
-BottomSheet component on iOS.
-Button
-
-iOS
-
-Code
-
-Button component on iOS.
-CircularProgress
-
-iOS
-
-Code
-
-CircularProgress component on iOS.
-ColorPicker
-
-iOS
-
-Code
-
-ColorPicker component on iOS.
-ContextMenu
-Note: Also known as DropdownMenu.
-
-iOS
-
-Code
-
-ContextMenu component on iOS.
-DateTimePicker (date)
-
-iOS
-
-Code
-
-DateTimePicker (date) component on iOS.
-DateTimePicker (time)
-
-iOS
-
-Code
-
-DateTimePicker (time) component on iOS.
-Gauge
-
-iOS
-
-Code
-
-Gauge component on iOS.
-LinearProgress
-
-iOS
-
-Code
-
-LinearProgress component on iOS.
-List
-
-iOS
-
-Code
-
-List component on iOS.
-Picker (segmented)
-
-iOS
-
-Code
-
-Picker component on iOS.
-Picker (wheel)
-
-iOS
-
-Code
-
-Picker component on iOS.
-Slider
-
-iOS
-
-Code
-
-Slider component on iOS.
-Switch (toggle)
-Note: Also known as Toggle.
-
-iOS
-
-Code
-
-Switch component on iOS.
-Switch (checkbox)
-
-iOS
-
-Code
-
-Picker component on iOS.
-TextInput
-
-iOS
-
-Code
-
-TextInput component on iOS.
-Jetpack Compose examples
-Button
-
-Android
-
-Code
-
-Button component on Android.
-CircularProgress
-
-Android
-
-Code
-
-CircularProgress component on Android.
-ContextMenu
-Note: Also known as DropdownMenu.
-
-Android
-
-Code
-
-ContextMenu component on Android.
-DateTimePicker (date)
-
-Android
-
-Code
-
-DateTimePicker component on Android.
-DateTimePicker (time)
-
-Android
-
-Code
-
-DateTimePicker (time) component on Android.
-LinearProgress
-
-Android
-
-Code
-
-LinearProgress component on Android.
-Picker (radio)
-
-Android
-
-Code
-
-Picker component (radio) on Android.
-Picker (segmented)
-
-Android
-
-Code
-
-Picker component on Android.
-Slider
-
-Android
-
-Code
-
-Slider component on Android.
-Switch (toggle)
-Note: Also known as Toggle.
-
-Android
-
-Code
-
-Switch component on Android.
-Switch (checkbox)
-
-Android
-
-Code
-
-Switch (checkbox variant) component on Android.
-TextInput
-
-Android
-
-Code
-
-TextInput component on Android.
-API
-Full documentation is not yet available. Use TypeScript types to explore the API.
-
-// Import from the SwiftUI package
-import { BottomSheet } from '@expo/ui/swift-ui';
-// Import from the Jetpack Compose package
-import { Button } from '@expo/ui/jetpack-compose';
diff --git a/apps/reader/apps/mobile/ReadMe/MinimalDatabase.md b/apps/reader/apps/mobile/ReadMe/MinimalDatabase.md
deleted file mode 100644
index f2f351a85..000000000
--- a/apps/reader/apps/mobile/ReadMe/MinimalDatabase.md
+++ /dev/null
@@ -1,562 +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 (
-
- {text.title}
-
- {!hasCache && (
-
- )}
-
- {generating && }
-
- {hasCache && (
- <>
- Audio gespeichert: {(text.data.audio.totalSize / 1024 / 1024).toFixed(2)} MB
-
-
- );
-};
-```
-
-## Vorteile dieser Struktur
-
-✅ **Eine Tabelle** = Keine Joins, keine Komplexität
-✅ **JSONB** = Unendlich erweiterbar ohne Migrations
-✅ **Performance** = PostgreSQL's JSONB ist super schnell
-✅ **Einfach** = Jeder versteht es sofort
-✅ **Flexibel** = Neue Features sind nur ein JSON-Feld entfernt
-
-## Erweiterungsbeispiele
-
-```javascript
-// Später: Lesezeichen hinzufügen
-data.bookmarks = [{ position: 1234, note: 'Wichtige Stelle', created: '2024-01-15' }];
-
-// Später: Sharing hinzufügen
-data.sharing = {
- isPublic: false,
- shareToken: 'abc123',
- sharedWith: ['email@example.com'],
-};
-
-// Später: AI-Features
-data.ai = {
- summary: 'KI-generierte Zusammenfassung',
- keywords: ['Thema1', 'Thema2'],
- difficulty: 'medium',
-};
-```
-
-## Das war's! 🎉
-
-Mit dieser einen Tabelle kannst du:
-
-- Texte speichern ✓
-- Vorlesen mit gespeicherter Position ✓
-- Tags/Kategorien verwalten ✓
-- Statistiken tracken ✓
-- Beliebig erweitern ✓
-
-Keine zweite Tabelle nötig. Kein Over-Engineering. Einfach machen.
diff --git a/apps/reader/apps/mobile/ReadMe/ProjectOverview.md b/apps/reader/apps/mobile/ReadMe/ProjectOverview.md
deleted file mode 100644
index dd58766f5..000000000
--- a/apps/reader/apps/mobile/ReadMe/ProjectOverview.md
+++ /dev/null
@@ -1,217 +0,0 @@
-# Reader - Projektübersicht
-
-## Was ist Reader?
-
-Reader ist eine moderne Text-to-Speech App, die Texte mit hochqualitativen KI-Stimmen vorliest und für die Offline-Nutzung speichert. Die App kombiniert die neuesten Google Chirp Stimmen mit einer eleganten Benutzeroberfläche und intelligenter Audio-Verwaltung.
-
-## Kernfunktionen
-
-### 📚 Text-Management
-
-- **Import**: Texte manuell eingeben oder aus Dateien importieren
-- **Organisation**: Einfache Tag-basierte Verwaltung
-- **Synchronisation**: Automatischer Sync zwischen Geräten via Supabase
-- **Lesefortschritt**: Merkt sich wo du aufgehört hast
-
-### 🎧 Premium Audio-Wiedergabe
-
-- **Google Chirp Stimmen**: Natürlich klingende KI-Stimmen in Studio-Qualität
-- **Offline-Verfügbarkeit**: Einmal generiert, immer verfügbar
-- **Anpassbar**: Geschwindigkeit und Tonhöhe individuell einstellbar
-- **Nahtlose Wiedergabe**: Intelligentes Chunk-System für unterbrechungsfreies Hören
-
-### 💾 Smart Caching
-
-- **Automatische Segmentierung**: Lange Texte werden intelligent aufgeteilt
-- **Progressives Laden**: Chunks werden bei Bedarf geladen
-- **Speicherverwaltung**: Übersicht über genutzten Speicherplatz
-- **Selective Sync**: Wähle welche Texte offline verfügbar sein sollen
-
-### 👤 Benutzerfreundlichkeit
-
-- **Ein-Klick-Generierung**: Audio für komplette Texte erstellen
-- **Hintergrund-Wiedergabe**: Weiterhören während andere Apps genutzt werden
-- **Sleep Timer**: Automatisches Stoppen nach eingestellter Zeit
-- **Lesezeichen**: Wichtige Stellen markieren
-
-## Technische Architektur
-
-### Frontend: React Native mit Expo
-
-- **Plattformen**: iOS und Android aus einer Codebasis
-- **UI Framework**: Native Komponenten für beste Performance
-- **Offline-First**: Funktioniert auch ohne Internetverbindung
-- **State Management**: React Context für einfache Datenverwaltung
-
-### Backend: Supabase
-
-- **Datenbank**: PostgreSQL mit einer minimalistischen Tabelle
-- **Authentifizierung**: Sichere Benutzerkonten out-of-the-box
-- **Realtime Sync**: Änderungen werden sofort synchronisiert
-- **Edge Functions**: Serverless Audio-Generierung
-
-### Audio-Pipeline: Google Cloud TTS
-
-- **Chirp Voices**: Neueste Generation von Google's Text-to-Speech
-- **Studio-Qualität**: Broadcast-taugliche Sprachausgabe
-- **Mehrsprachig**: Unterstützung für 40+ Sprachen
-- **Neural Synthesis**: KI-basierte Sprachgenerierung
-
-## Projektkonzept für Google Chirp Integration
-
-### Phase 1: Infrastruktur-Setup
-
-**Ziel**: Grundlegende Verbindungen zwischen allen Systemen herstellen
-
-**Google Cloud Konfiguration**:
-
-- Google Cloud Projekt erstellen und Text-to-Speech API aktivieren
-- Service Account für sichere API-Zugriffe einrichten
-- Zugriffsschlüssel generieren und sicher speichern
-- Kostenkontrolle durch Quotas und Budgets einrichten
-
-**Supabase Edge Functions Setup**:
-
-- Zwei Hauptfunktionen: Audio-Generierung und Batch-Processing
-- Sichere Speicherung der Google Cloud Credentials
-- CORS-Konfiguration für App-Zugriffe
-- Error Handling und Logging-Strategie
-
-### Phase 2: Audio-Generierungs-Pipeline
-
-**Ziel**: Robuste und skalierbare Audio-Erstellung
-
-**Text-Segmentierung**:
-
-- Intelligente Aufteilung an Satzgrenzen
-- Optimale Chunk-Größe für Balance zwischen Qualität und Performance
-- Metadaten für nahtlose Wiedergabe speichern
-
-**Batch-Processing**:
-
-- Parallele Verarbeitung mit Rate Limiting
-- Fortschrittsanzeige für Benutzer
-- Fehlerbehandlung für einzelne Chunks
-- Automatische Wiederholung bei Fehlern
-
-**Storage-Strategie**:
-
-- Supabase Storage für zentrale Audio-Dateien
-- Signierte URLs mit Ablaufzeit
-- Lokaler Cache auf Geräten
-- Intelligente Garbage Collection
-
-### Phase 3: App-Integration
-
-**Ziel**: Nahtlose Benutzererfahrung
-
-**Audio-Service Layer**:
-
-- Abstraktion der Komplexität
-- Queue-Management für Wiedergabe
-- Prefetching für unterbrechungsfreies Hören
-- Fallback-Mechanismen
-
-**UI/UX Konzepte**:
-
-- Ein-Tap Audio-Generierung
-- Visuelles Feedback während Processing
-- Download-Progress für Offline-Sync
-- Intuitive Playback-Controls
-
-### Phase 4: Optimierung & Skalierung
-
-**Ziel**: Production-ready System
-
-**Performance**:
-
-- CDN-Integration für schnelle Downloads
-- Chunk-Größen-Optimierung
-- Parallele Downloads
-- Background Processing
-
-**Kosten-Optimierung**:
-
-- Caching bereits generierter Audios
-- Deduplizierung gleicher Textpassagen
-- Nutzungsbasierte Limits
-- Premium-Tier für Heavy Users
-
-**Monitoring**:
-
-- Verwendungsstatistiken
-- Error Tracking
-- Performance Metriken
-- Kosten-Überwachung
-
-## Alleinstellungsmerkmale
-
-### 🎯 Was Reader besonders macht:
-
-1. **Höchste Audioqualität**: Google Chirp Stimmen klingen natürlicher als Standard TTS
-2. **True Offline**: Einmal generiert, für immer verfügbar - kein Streaming nötig
-3. **Minimalistisches Design**: Fokus auf das Wesentliche ohne überflüssige Features
-4. **Privacy-First**: Deine Texte bleiben deine Texte
-5. **Fair Pricing**: Einmalige Generierung statt ständige Streaming-Kosten
-
-## Monetarisierung
-
-### Freemium Modell:
-
-- **Free Tier**: 10.000 Zeichen/Monat
-- **Pro**: 500.000 Zeichen/Monat + Premium Stimmen
-- **Team**: Unbegrenzt + Collaboration Features
-
-### Kostenstruktur:
-
-- Google TTS: ~$16 per 1 Million Zeichen (Chirp Voices)
-- Supabase: $25/Monat für Pro Features
-- Storage: $0.021 per GB/Monat
-
-## Zeitplan
-
-**Woche 1-2**: Setup & Basis-Integration
-
-- Google Cloud und Supabase konfigurieren
-- Edge Functions entwickeln
-- Basis-App mit Authentifizierung
-
-**Woche 3-4**: Audio-Pipeline
-
-- Chunk-System implementieren
-- Storage-Integration
-- Playback-Funktionalität
-
-**Woche 5-6**: Polish & Launch
-
-- UI/UX Verfeinerung
-- Testing & Bugfixing
-- App Store Vorbereitung
-
-## Erfolgsmetriken
-
-- **Nutzer-Aktivierung**: 80% generieren ersten Audio innerhalb 5 Minuten
-- **Retention**: 40% Daily Active Users
-- **Audio-Qualität**: <2% Neu-Generierungen wegen Qualität
-- **Performance**: <3 Sekunden für Start der Wiedergabe
-- **Conversion**: 5% Free-to-Pro nach 30 Tagen
-
-## Risiken & Mitigationen
-
-**API-Kosten**:
-
-- Monitoring und Alerts
-- Caching-Strategien
-- User Limits
-
-**Technische Komplexität**:
-
-- Schrittweise Integration
-- Ausführliches Testing
-- Fallback-Optionen
-
-**Skalierung**:
-
-- Edge Function Limits beachten
-- CDN frühzeitig einplanen
-- Horizontale Skalierung vorbereiten
diff --git a/apps/reader/apps/mobile/app-env.d.ts b/apps/reader/apps/mobile/app-env.d.ts
deleted file mode 100644
index 88dc403ea..000000000
--- a/apps/reader/apps/mobile/app-env.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// @ts-ignore
-///
diff --git a/apps/reader/apps/mobile/app.json b/apps/reader/apps/mobile/app.json
deleted file mode 100644
index 8ea34c4ae..000000000
--- a/apps/reader/apps/mobile/app.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "expo": {
- "name": "reader",
- "slug": "reader",
- "version": "1.0.0",
- "scheme": "reader",
- "web": {
- "bundler": "metro",
- "output": "static",
- "favicon": "./assets/favicon.png"
- },
- "plugins": [
- "expo-router",
- [
- "expo-dev-launcher",
- {
- "launchMode": "most-recent"
- }
- ],
- "expo-web-browser"
- ],
- "experiments": {
- "typedRoutes": true,
- "tsconfigPaths": true
- },
- "orientation": "portrait",
- "icon": "./assets/icon.png",
- "userInterfaceStyle": "light",
- "splash": {
- "image": "./assets/splash.png",
- "resizeMode": "contain",
- "backgroundColor": "#ffffff"
- },
- "assetBundlePatterns": ["**/*"],
- "ios": {
- "supportsTablet": true,
- "bundleIdentifier": "com.tilljs.reader"
- },
- "android": {
- "adaptiveIcon": {
- "foregroundImage": "./assets/adaptive-icon.png",
- "backgroundColor": "#ffffff"
- },
- "package": "com.tilljs.reader"
- }
- }
-}
diff --git a/apps/reader/apps/mobile/app/(auth)/_layout.tsx b/apps/reader/apps/mobile/app/(auth)/_layout.tsx
deleted file mode 100644
index 04c25cbae..000000000
--- a/apps/reader/apps/mobile/app/(auth)/_layout.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Stack } from 'expo-router';
-
-export default function AuthLayout() {
- return (
-
-
-
-
-
- );
-}
diff --git a/apps/reader/apps/mobile/app/(auth)/forgot-password.tsx b/apps/reader/apps/mobile/app/(auth)/forgot-password.tsx
deleted file mode 100644
index d44424aa3..000000000
--- a/apps/reader/apps/mobile/app/(auth)/forgot-password.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import React, { useState } from 'react';
-import {
- View,
- Text,
- TextInput,
- Pressable,
- ActivityIndicator,
- KeyboardAvoidingView,
- Platform,
-} from 'react-native';
-import { Link } from 'expo-router';
-import { useAuth } from '~/hooks/useAuth';
-
-export default function ForgotPasswordScreen() {
- const [email, setEmail] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const [success, setSuccess] = useState(false);
- const { resetPassword } = useAuth();
-
- const handleResetPassword = async () => {
- if (!email) {
- setError('Bitte gib deine E-Mail-Adresse ein');
- return;
- }
-
- setLoading(true);
- setError(null);
-
- const { error } = await resetPassword(email);
-
- if (error) {
- setError(error);
- setLoading(false);
- } else {
- setSuccess(true);
- setLoading(false);
- }
- };
-
- if (success) {
- return (
-
-
- E-Mail gesendet!
-
- Wir haben dir einen Link zum Zurücksetzen deines Passworts gesendet. Überprüfe deine
- E-Mails und folge den Anweisungen.
-
-
-
-
- Zurück zum Login
-
-
-
-
- );
- }
-
- return (
-
-
-
- Passwort zurücksetzen
-
- Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen
-
-
-
- {error && (
-
- {error}
-
- )}
-
-
-
- E-Mail
-
-
-
-
- {loading ? (
-
- ) : (
-
- Reset-Link senden
-
- )}
-
-
-
- Erinnerst du dich wieder?
-
-
- Anmelden
-
-
-
-
-
-
- );
-}
diff --git a/apps/reader/apps/mobile/app/(auth)/login.tsx b/apps/reader/apps/mobile/app/(auth)/login.tsx
deleted file mode 100644
index 8478c0a0a..000000000
--- a/apps/reader/apps/mobile/app/(auth)/login.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import React, { useState } from 'react';
-import {
- View,
- Text,
- TextInput,
- Pressable,
- ActivityIndicator,
- KeyboardAvoidingView,
- Platform,
-} from 'react-native';
-import { Link, router } from 'expo-router';
-import { useAuth } from '~/hooks/useAuth';
-import { useTheme } from '~/hooks/useTheme';
-
-export default function LoginScreen() {
- const [email, setEmail] = useState('');
- const [password, setPassword] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const { signIn } = useAuth();
- const { colors } = useTheme();
-
- const handleLogin = async () => {
- if (!email || !password) {
- setError('Bitte fülle alle Felder aus');
- return;
- }
-
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
- setError('Bitte gib eine gültige E-Mail-Adresse ein');
- return;
- }
-
- setLoading(true);
- setError(null);
-
- const { error } = await signIn(email, password);
-
- if (error) {
- setError(error);
- setLoading(false);
- } else {
- router.replace('/(tabs)');
- }
- };
-
- return (
-
-
-
- Willkommen zurück
- Melde dich an, um fortzufahren
-
-
- {error && (
-
- {error}
-
- )}
-
-
-
- E-Mail
-
-
-
-
- Passwort
-
-
-
-
- {loading ? (
-
- ) : (
- Anmelden
- )}
-
-
-
- Noch kein Konto?
-
-
- Registrieren
-
-
-
-
-
-
- Passwort vergessen?
-
-
-
-
-
- );
-}
diff --git a/apps/reader/apps/mobile/app/(auth)/register.tsx b/apps/reader/apps/mobile/app/(auth)/register.tsx
deleted file mode 100644
index e157ff197..000000000
--- a/apps/reader/apps/mobile/app/(auth)/register.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-import React, { useState } from 'react';
-import {
- View,
- Text,
- TextInput,
- Pressable,
- ActivityIndicator,
- KeyboardAvoidingView,
- Platform,
-} from 'react-native';
-import { Link, router } from 'expo-router';
-import { useAuth } from '~/hooks/useAuth';
-
-export default function RegisterScreen() {
- const [email, setEmail] = useState('');
- const [password, setPassword] = useState('');
- const [confirmPassword, setConfirmPassword] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const { signUp } = useAuth();
-
- const handleRegister = async () => {
- if (!email || !password || !confirmPassword) {
- setError('Bitte fülle alle Felder aus');
- return;
- }
-
- if (password !== confirmPassword) {
- setError('Passwörter stimmen nicht überein');
- return;
- }
-
- if (password.length < 6) {
- setError('Passwort muss mindestens 6 Zeichen lang sein');
- return;
- }
-
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
- setError('Bitte gib eine gültige E-Mail-Adresse ein');
- return;
- }
-
- setLoading(true);
- setError(null);
-
- const { error } = await signUp(email, password);
-
- if (error) {
- setError(error);
- setLoading(false);
- } else {
- router.replace('/(tabs)');
- }
- };
-
- return (
-
-
-
- Konto erstellen
- Registriere dich für Reader
-
-
- {error && (
-
- {error}
-
- )}
-
-
-
- E-Mail
-
-
-
-
- Passwort
-
-
-
-
- Passwort bestätigen
-
-
-
-
- {loading ? (
-
- ) : (
- Registrieren
- )}
-
-
-
- Schon ein Konto?
-
-
- Anmelden
-
-
-
-
-
-
- );
-}
diff --git a/apps/reader/apps/mobile/app/(tabs)/_layout.tsx b/apps/reader/apps/mobile/app/(tabs)/_layout.tsx
deleted file mode 100644
index 2cde5262f..000000000
--- a/apps/reader/apps/mobile/app/(tabs)/_layout.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Tabs } from 'expo-router';
-import { TabBarIcon } from '../../components/TabBarIcon';
-import { useTheme } from '~/hooks/useTheme';
-
-export default function TabLayout() {
- const { colors } = useTheme();
-
- return (
-
- ,
- }}
- />
- ,
- }}
- />
-
- );
-}
diff --git a/apps/reader/apps/mobile/app/(tabs)/index.tsx b/apps/reader/apps/mobile/app/(tabs)/index.tsx
deleted file mode 100644
index 5790467eb..000000000
--- a/apps/reader/apps/mobile/app/(tabs)/index.tsx
+++ /dev/null
@@ -1,342 +0,0 @@
-import React, { useMemo, useState, useEffect } from 'react';
-import {
- View,
- Text,
- FlatList,
- Pressable,
- ActivityIndicator,
- Alert,
- Share,
- AppState,
- ScrollView,
-} from 'react-native';
-import { Stack, router, useFocusEffect } from 'expo-router';
-import { useTexts } from '~/hooks/useTexts';
-import { useAuth } from '~/hooks/useAuth';
-import { useStore } from '~/store/store';
-import { Text as TextType, AudioVersion } from '~/types/database';
-import { TagFilter } from '~/components/TagFilter';
-import { useTheme } from '~/hooks/useTheme';
-import { Header } from '~/components/Header';
-import { FloatingActionButton } from '~/components/FloatingActionButton';
-import { TextListItem } from '~/components/TextListItem';
-import * as Clipboard from 'expo-clipboard';
-import { urlExtractorService } from '~/services/urlExtractorService';
-
-export default function Home() {
- const { texts, loading, error, refetch, deleteText, createText } = useTexts();
- const { signOut } = useAuth();
- const { selectedTags, settings } = useStore();
- const { colors } = useTheme();
- const [extracting, setExtracting] = useState(false);
- const [clipboardHasUrl, setClipboardHasUrl] = useState(false);
-
- // Check clipboard content on mount and when app becomes active
- useEffect(() => {
- const checkClipboard = async () => {
- try {
- const content = await Clipboard.getStringAsync();
- const hasUrl = content ? urlExtractorService.validateUrl(content) : false;
- setClipboardHasUrl(hasUrl);
- } catch (error) {
- console.error('Error checking clipboard:', error);
- setClipboardHasUrl(false);
- }
- };
-
- // Check on mount
- checkClipboard();
-
- // Check when app becomes active
- const subscription = AppState.addEventListener('change', (nextAppState) => {
- if (nextAppState === 'active') {
- checkClipboard();
- }
- });
-
- return () => {
- subscription.remove();
- };
- }, []);
-
- // Refresh texts when screen comes into focus
- useFocusEffect(
- React.useCallback(() => {
- refetch();
- }, [])
- );
-
- // Filter texts based on selected tags
- const filteredTexts = useMemo(() => {
- if (selectedTags.length === 0) {
- return texts;
- }
-
- return texts.filter((text) => {
- const textTags = text.data.tags || [];
- return selectedTags.every((tag) => textTags.includes(tag));
- });
- }, [texts, selectedTags]);
-
- const formatDate = (dateString: string) => {
- const date = new Date(dateString);
- return date.toLocaleDateString('de-DE', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- });
- };
-
- const formatDuration = (totalTime: number) => {
- const hours = Math.floor(totalTime / 3600);
- const minutes = Math.floor((totalTime % 3600) / 60);
- const seconds = Math.floor(totalTime % 60);
-
- if (hours > 0) {
- return `${hours}h ${minutes}m`;
- }
- if (minutes > 0) {
- return `${minutes}m`;
- }
- return `${seconds} Sek`;
- };
-
- const getAudioDuration = (item: TextType) => {
- // Try to get duration from current audio version
- if (item.data.audioVersions && item.data.audioVersions.length > 0) {
- const currentVersionId = item.data.currentAudioVersion;
- const currentVersion = currentVersionId
- ? item.data.audioVersions.find((v) => v.id === currentVersionId)
- : item.data.audioVersions[item.data.audioVersions.length - 1];
-
- if (currentVersion && currentVersion.chunks) {
- const totalSeconds = currentVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0);
- return formatDuration(totalSeconds);
- }
- }
-
- // Fallback to legacy audio data
- if (item.data.audio && item.data.audio.chunks) {
- const totalSeconds = item.data.audio.chunks.reduce((sum, chunk) => sum + chunk.duration, 0);
- return formatDuration(totalSeconds);
- }
-
- return null;
- };
-
- const handleDelete = async (textId: string, title: string) => {
- Alert.alert('Text löschen', `Möchten Sie "${title}" wirklich löschen?`, [
- {
- text: 'Abbrechen',
- style: 'cancel',
- },
- {
- text: 'Löschen',
- style: 'destructive',
- onPress: async () => {
- const { error } = await deleteText(textId);
- if (error) {
- Alert.alert('Fehler', error);
- } else {
- // Manually refresh the list after successful deletion
- refetch();
- }
- },
- },
- ]);
- };
-
- const handleShare = async (text: TextType) => {
- try {
- const message = `${text.title}\n\n${text.content}`;
- await Share.share({
- title: text.title,
- message: message,
- });
- } catch (error) {
- console.error('Error sharing:', error);
- }
- };
-
- const handleClipboardUrl = async () => {
- try {
- setExtracting(true);
- const clipboardContent = await Clipboard.getStringAsync();
-
- if (!clipboardContent) {
- Alert.alert(
- 'Zwischenablage leer',
- 'Bitte kopieren Sie zuerst eine URL in die Zwischenablage.'
- );
- setExtracting(false);
- return;
- }
-
- // Check if it's a valid URL
- if (!urlExtractorService.validateUrl(clipboardContent)) {
- Alert.alert(
- 'Keine gültige URL',
- 'Die Zwischenablage enthält keine gültige URL. Bitte kopieren Sie eine Webadresse und versuchen Sie es erneut.'
- );
- setExtracting(false);
- return;
- }
-
- // Extract content from URL
- const { data, error: extractError } =
- await urlExtractorService.extractFromUrl(clipboardContent);
-
- if (extractError) {
- Alert.alert(
- 'Fehler beim Abrufen',
- `Die Webseite konnte nicht geladen werden: ${extractError.message}`
- );
- setExtracting(false);
- return;
- }
-
- if (data) {
- // Create the text with extracted content
- const { data: createdText, error: createError } = await createText(
- data.title,
- urlExtractorService.formatExtractedContent(data),
- {
- tags: data.tags,
- source: data.source,
- tts: { speed: settings.speed || 1.0, voice: settings.voice || 'de-DE-Neural2-A' },
- }
- );
-
- if (createError) {
- Alert.alert(
- 'Fehler beim Speichern',
- `Der Text konnte nicht gespeichert werden: ${createError}`
- );
- } else if (createdText) {
- // Refresh the list before navigating
- await refetch();
- // Navigate to the newly created text
- router.push(`/text/${createdText.id}`);
- }
- }
- } catch (error) {
- console.error('Error processing clipboard URL:', error);
- Alert.alert(
- 'Unerwarteter Fehler',
- 'Beim Verarbeiten der URL ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'
- );
- } finally {
- setExtracting(false);
- }
- };
-
- const renderTextItem = ({ item }: { item: TextType }) => (
-
- );
-
- if (loading) {
- return (
- <>
-
-
-
-
- Texte werden geladen...
-
- >
- );
- }
-
- if (error) {
- return (
- <>
-
-
-
- {error}
- refetch()} className={`rounded-lg ${colors.primary} px-4 py-2`}>
- Erneut versuchen
-
-
- >
- );
- }
-
- return (
- <>
-
-
-
-
-
-
- {texts.length === 0 ? (
-
-
- Noch keine Texte vorhanden
-
- router.push('/add-text')}
- className={`rounded-lg ${colors.primary} px-6 py-3`}
- >
- Ersten Text hinzufügen
-
-
- ) : filteredTexts.length === 0 ? (
-
-
- Keine Texte mit den gewählten Tags gefunden
-
- router.push('/add-text')}
- className={`rounded-lg ${colors.primary} px-6 py-3`}
- >
- Neuen Text hinzufügen
-
-
- ) : (
- item.id}
- contentContainerStyle={{ padding: 16, paddingBottom: 100 }}
- showsVerticalScrollIndicator={false}
- />
- )}
-
-
-
- router.push('/add-text')}
- icon="+"
- label="Neuer Text"
- style={{ marginRight: 12 }}
- />
-
-
-
-
-
- >
- );
-}
diff --git a/apps/reader/apps/mobile/app/(tabs)/two.tsx b/apps/reader/apps/mobile/app/(tabs)/two.tsx
deleted file mode 100644
index be4b67e51..000000000
--- a/apps/reader/apps/mobile/app/(tabs)/two.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import React from 'react';
-import { View, Text, Pressable, ScrollView } from 'react-native';
-import { Stack, router } from 'expo-router';
-import { useStore } from '~/store/store';
-import { useAuth } from '~/hooks/useAuth';
-import { useTexts } from '~/hooks/useTexts';
-import { useTheme } from '~/hooks/useTheme';
-import { Header } from '~/components/Header';
-import { Dropdown } from '~/components/dropdown';
-import {
- GERMAN_VOICES,
- QUALITY_LABELS,
- PROVIDER_LABELS,
- getVoiceById,
- LEGACY_VOICE_MAP,
-} from '~/constants/voices';
-
-export default function SettingsScreen() {
- const { settings, updateSettings } = useStore();
- const { user, signOut } = useAuth();
- const { texts, getAllTags } = useTexts();
- const { colors } = useTheme();
-
- // Map legacy voice settings to new voice IDs
- const currentVoice = LEGACY_VOICE_MAP[settings.voice] || settings.voice || 'de-DE-Neural2-A';
-
- const speeds = [
- { value: 0.5, label: 'Langsam (0.5x)' },
- { value: 0.75, label: 'Etwas langsam (0.75x)' },
- { value: 1.0, label: 'Normal (1.0x)' },
- { value: 1.25, label: 'Etwas schnell (1.25x)' },
- { value: 1.5, label: 'Schnell (1.5x)' },
- { value: 2.0, label: 'Sehr schnell (2.0x)' },
- ];
-
- const themes = [
- { value: 'light', label: 'Hell' },
- { value: 'dark', label: 'Dunkel' },
- ];
-
- const totalTexts = texts.length;
- const totalTags = getAllTags().length;
- const textsWithAudio = texts.filter((t) => t.data.audio?.hasLocalCache).length;
- const totalAudioSize = texts.reduce((sum, text) => {
- return sum + (text.data.audio?.totalSize || 0);
- }, 0);
-
- const handleLogout = async () => {
- await signOut();
- router.replace('/(auth)/login');
- };
-
- return (
- <>
-
-
-
-
-
- {/* Statistics */}
-
- Statistiken
-
-
-
- Texte gesamt:
- {totalTexts}
-
-
-
- Tags:
- {totalTags}
-
-
-
- Texte mit Audio:
- {textsWithAudio}
-
-
-
- Audio-Speicher:
-
- {(totalAudioSize / 1024 / 1024).toFixed(2)} MB
-
-
-
-
-
- {/* Audio Settings */}
-
- Audio-Einstellungen
-
-
- Stimme
- updateSettings({ voice: newVoice })}
- placeholder="Stimme wählen"
- title="Stimme auswählen"
- groups={Object.entries(
- GERMAN_VOICES.reduce(
- (groups, voice) => {
- const provider = voice.provider;
- if (!groups[provider]) {
- groups[provider] = {};
- }
- const quality = voice.quality;
- if (!groups[provider][quality]) {
- groups[provider][quality] = [];
- }
- groups[provider][quality].push(voice);
- return groups;
- },
- {} as Record>
- )
- ).map(([provider, qualityGroups]) => ({
- title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
- options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
- voices.map((voice) => ({
- label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
- value: voice.value,
- }))
- ),
- }))}
- />
-
-
-
-
- Geschwindigkeit
-
-
- {speeds.map((speed) => (
- updateSettings({ speed: speed.value })}
- className={`rounded-lg border p-3 ${
- settings.speed === speed.value
- ? `border-blue-500 ${colors.primaryLight}`
- : colors.border
- }`}
- >
-
- {speed.label}
-
-
- ))}
-
-
-
-
- {/* App Settings */}
-
- App-Einstellungen
-
-
- Design
-
- {themes.map((theme) => (
- updateSettings({ theme: theme.value as 'light' | 'dark' })}
- className={`rounded-lg border p-3 ${
- settings.theme === theme.value
- ? `border-blue-500 ${colors.primaryLight}`
- : colors.border
- }`}
- >
-
- {theme.label}
-
-
- ))}
-
-
-
-
- {/* App Info */}
-
- App Info
-
-
-
- Version:
- 1.0.0
-
-
-
- Build:
- 1
-
-
-
-
- {/* User Info */}
-
- Konto
- {user?.email}
-
- Abmelden
-
-
-
-
- >
- );
-}
diff --git a/apps/reader/apps/mobile/app/+html.tsx b/apps/reader/apps/mobile/app/+html.tsx
deleted file mode 100644
index 447d6a0a5..000000000
--- a/apps/reader/apps/mobile/app/+html.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { ScrollViewStyleReset } from 'expo-router/html';
-
-// This file is web-only and used to configure the root HTML for every
-// web page during static rendering.
-// The contents of this function only run in Node.js environments and
-// do not have access to the DOM or browser APIs.
-export default function Root({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
-
- {/*
- This viewport disables scaling which makes the mobile website act more like a native app.
- However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
-
- */}
-
- {/*
- Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
- However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
- */}
-
-
- {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
-
- {/* Add any additional elements that you want globally available on web... */}
-
- {children}
-
- );
-}
-
-const responsiveBackground = `
-body {
- background-color: #fff;
-}
-@media (prefers-color-scheme: dark) {
- body {
- background-color: #000;
- }
-}`;
diff --git a/apps/reader/apps/mobile/app/+not-found.tsx b/apps/reader/apps/mobile/app/+not-found.tsx
deleted file mode 100644
index cec89e5ce..000000000
--- a/apps/reader/apps/mobile/app/+not-found.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Link, Stack } from 'expo-router';
-
-import { Text, View } from 'react-native';
-
-export default function NotFoundScreen() {
- return (
- <>
-
-
- {"This screen doesn't exist."}
-
- Go to home screen!
-
-
- >
- );
-}
-
-const styles = {
- container: `items-center flex-1 justify-center p-5`,
- title: `text-xl font-bold`,
- link: `mt-4 pt-4`,
- linkText: `text-base text-[#2e78b7]`,
-};
diff --git a/apps/reader/apps/mobile/app/_layout.tsx b/apps/reader/apps/mobile/app/_layout.tsx
deleted file mode 100644
index 07db95fe2..000000000
--- a/apps/reader/apps/mobile/app/_layout.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-// Polyfill for structuredClone (not available in React Native 0.79.5)
-import '../global.css';
-
-import { Stack, router } from 'expo-router';
-import { useAuth } from '~/hooks/useAuth';
-import { useEffect } from 'react';
-
-if (typeof globalThis.structuredClone === 'undefined') {
- globalThis.structuredClone = (obj: any) => JSON.parse(JSON.stringify(obj));
-}
-
-export const unstable_settings = {
- initialRouteName: '(tabs)',
-};
-
-export default function RootLayout() {
- const { user, loading } = useAuth();
-
- useEffect(() => {
- if (!loading) {
- if (user) {
- router.replace('/(tabs)');
- } else {
- router.replace('/(auth)/login');
- }
- }
- }, [user, loading]);
-
- return (
-
-
-
-
- );
-}
diff --git a/apps/reader/apps/mobile/app/add-text.tsx b/apps/reader/apps/mobile/app/add-text.tsx
deleted file mode 100644
index 079f5bab5..000000000
--- a/apps/reader/apps/mobile/app/add-text.tsx
+++ /dev/null
@@ -1,274 +0,0 @@
-import React, { useState, useCallback } from 'react';
-import {
- View,
- Text,
- TextInput,
- Pressable,
- ActivityIndicator,
- KeyboardAvoidingView,
- Platform,
- ScrollView,
-} from 'react-native';
-import { Stack, router, useFocusEffect } from 'expo-router';
-import { useTexts } from '~/hooks/useTexts';
-import { Header } from '~/components/Header';
-import { useTheme } from '~/hooks/useTheme';
-import { useStore } from '~/store/store';
-import { Dropdown } from '~/components/dropdown';
-import { GERMAN_VOICES, QUALITY_LABELS, PROVIDER_LABELS, getVoiceById } from '~/constants/voices';
-import { urlExtractorService } from '~/services/urlExtractorService';
-
-export default function AddTextScreen() {
- const [title, setTitle] = useState('');
- const [content, setContent] = useState('');
- const [tags, setTags] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const { createText, refetch } = useTexts();
- const { colors } = useTheme();
- const { settings } = useStore();
- const [selectedVoice, setSelectedVoice] = useState(settings.voice || 'de-DE-Neural2-A');
- const [inputMode, setInputMode] = useState<'text' | 'url'>('text');
- const [url, setUrl] = useState('');
- const [extracting, setExtracting] = useState(false);
-
- const handleExtractUrl = async () => {
- if (!url.trim()) {
- setError('Bitte gib eine URL ein');
- return;
- }
-
- setExtracting(true);
- setError(null);
-
- const { data, error: extractError } = await urlExtractorService.extractFromUrl(url);
-
- setExtracting(false);
-
- if (extractError) {
- setError(extractError.message);
- return;
- }
-
- if (data) {
- setTitle(data.title);
- setContent(urlExtractorService.formatExtractedContent(data));
- if (data.tags.length > 0) {
- setTags(data.tags.join(', '));
- }
- }
- };
-
- const handleSave = async () => {
- if (!title.trim()) {
- setError('Bitte gib einen Titel ein');
- return;
- }
-
- if (!content.trim()) {
- setError('Bitte gib einen Text ein');
- return;
- }
-
- setLoading(true);
- setError(null);
-
- const tagsArray = tags
- .split(',')
- .map((tag) => tag.trim())
- .filter((tag) => tag.length > 0);
-
- try {
- const { data, error } = await createText(title.trim(), content.trim(), {
- tags: tagsArray,
- tts: { speed: settings.speed || 1.0, voice: selectedVoice },
- source: inputMode === 'url' ? url : undefined,
- });
-
- if (error) {
- console.error('Error creating text:', error);
- setError(error);
- setLoading(false);
- } else {
- console.log('Text created successfully:', data);
- // Navigate back immediately - the list will refresh via useFocusEffect
- router.back();
- }
- } catch (err) {
- console.error('Unexpected error:', err);
- setError(err instanceof Error ? err.message : 'Unerwarteter Fehler');
- setLoading(false);
- }
- };
-
- return (
-
-
-
- {loading ? (
-
- ) : (
- Speichern
- )}
-
- }
- />
-
-
- {error && (
-
- {error}
-
- )}
-
-
- Titel
-
-
-
-
-
- Tags (durch Komma getrennt)
-
-
-
-
-
- Stimme
- {
- const provider = voice.provider;
- if (!groups[provider]) {
- groups[provider] = {};
- }
- const quality = voice.quality;
- if (!groups[provider][quality]) {
- groups[provider][quality] = [];
- }
- groups[provider][quality].push(voice);
- return groups;
- },
- {} as Record>
- )
- ).map(([provider, qualityGroups]) => ({
- title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
- options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
- voices.map((voice) => ({
- label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
- value: voice.value,
- }))
- ),
- }))}
- />
-
-
-
-
- setInputMode('text')}
- className={`mr-2 rounded-lg px-4 py-2 ${inputMode === 'text' ? colors.primary : colors.surface}`}
- >
-
- Text
-
-
- setInputMode('url')}
- className={`rounded-lg px-4 py-2 ${inputMode === 'url' ? colors.primary : colors.surface}`}
- >
-
- URL
-
-
-
-
- {inputMode === 'text' ? (
-
- ) : (
-
-
-
- {extracting ? (
-
- ) : (
- Text extrahieren
- )}
-
- {content && (
-
- )}
-
- )}
-
-
-
-
- 💡 Tipp: Du kannst später Audio für diesen Text generieren und offline anhören.
-
-
-
-
- {loading ? (
-
- ) : (
- Speichern
- )}
-
-
-
- );
-}
diff --git a/apps/reader/apps/mobile/app/text/[id].tsx b/apps/reader/apps/mobile/app/text/[id].tsx
deleted file mode 100644
index 203c3728c..000000000
--- a/apps/reader/apps/mobile/app/text/[id].tsx
+++ /dev/null
@@ -1,218 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { View, ActivityIndicator, ScrollView, Alert, Pressable } from 'react-native';
-import { Stack, router, useLocalSearchParams } from 'expo-router';
-import { useTexts } from '~/hooks/useTexts';
-import { Text as TextType } from '~/types/database';
-import { AudioPlayer } from '~/components/AudioPlayer';
-import { Button } from '~/components/Button';
-import { Text } from '~/components/Text';
-import { Header } from '~/components/Header';
-import { Icon } from '~/components/Icon';
-import { useTheme } from '~/hooks/useTheme';
-
-export default function TextDetailScreen() {
- const { id } = useLocalSearchParams<{ id: string }>();
- const { texts, deleteText } = useTexts();
- const [text, setText] = useState(null);
- const [loading, setLoading] = useState(true);
- const { colors } = useTheme();
-
- useEffect(() => {
- const foundText = texts.find((t) => t.id === id);
- setText(foundText || null);
- setLoading(false);
- }, [id, texts]);
-
- const handleDelete = () => {
- Alert.alert(
- 'Text löschen',
- 'Möchtest du diesen Text wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
- [
- { text: 'Abbrechen', style: 'cancel' },
- {
- text: 'Löschen',
- style: 'destructive',
- onPress: async () => {
- if (text) {
- const { error } = await deleteText(text.id);
- if (!error) {
- router.back();
- }
- }
- },
- },
- ]
- );
- };
-
- const formatDate = (dateString: string) => {
- const date = new Date(dateString);
- return date.toLocaleDateString('de-DE', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- };
-
- if (loading) {
- return (
- <>
-
-
-
-
-
- >
- );
- }
-
- if (!text) {
- return (
- <>
-
-
-
-
- Der angeforderte Text wurde nicht gefunden.
-
- router.back()}
- className={`rounded-lg ${colors.primary} px-4 py-2`}
- >
- Zurück
-
-
- >
- );
- }
-
- return (
- <>
-
-
-
-
- }
- />
-
-
-
-
-
- {text.title}
-
-
-
-
- Erstellt: {formatDate(text.created_at)}
-
- {text.updated_at !== text.created_at && (
-
- Bearbeitet: {formatDate(text.updated_at)}
-
- )}
-
-
- {text.data.tags && text.data.tags.length > 0 ? (
-
- {text.data.tags.map((tag, index) => (
-
-
- {tag}
-
-
- ))}
-
- ) : null}
-
-
-
-
- {text.content}
-
-
-
- {
- // Refresh text data after audio generation
- const updatedText = texts.find((t) => t.id === text.id);
- if (updatedText) {
- setText(updatedText);
- }
- }}
- />
-
- {text.data.stats ? (
-
-
- Statistiken
-
-
-
-
- Wiedergaben:
- {text.data.stats?.playCount || 0}
-
-
- {text.data.stats?.totalTime ? (
-
- Gesamtzeit:
-
- {Math.floor(text.data.stats.totalTime / 60)}m{' '}
- {Math.round(text.data.stats.totalTime % 60)}s
-
-
- ) : null}
-
-
- Status:
- {text.data.stats?.completed ? 'Abgeschlossen' : 'In Progress'}
-
-
-
- ) : null}
-
- {text.data.audio?.hasLocalCache ? (
-
-
- Audio Cache
-
-
-
-
- Chunks:
- {text.data.audio?.chunks?.length || 0}
-
-
-
- Größe:
- {((text.data.audio?.totalSize || 0) / 1024 / 1024).toFixed(2)} MB
-
-
- {text.data.audio?.lastGenerated ? (
-
- Generiert:
- {formatDate(text.data.audio.lastGenerated)}
-
- ) : null}
-
-
- ) : null}
-
-
- >
- );
-}
diff --git a/apps/reader/apps/mobile/assets/adaptive-icon.png b/apps/reader/apps/mobile/assets/adaptive-icon.png
deleted file mode 100644
index 03d6f6b6c..000000000
Binary files a/apps/reader/apps/mobile/assets/adaptive-icon.png and /dev/null differ
diff --git a/apps/reader/apps/mobile/assets/favicon.png b/apps/reader/apps/mobile/assets/favicon.png
deleted file mode 100644
index e75f697b1..000000000
Binary files a/apps/reader/apps/mobile/assets/favicon.png and /dev/null differ
diff --git a/apps/reader/apps/mobile/assets/icon.png b/apps/reader/apps/mobile/assets/icon.png
deleted file mode 100644
index a0b1526fc..000000000
Binary files a/apps/reader/apps/mobile/assets/icon.png and /dev/null differ
diff --git a/apps/reader/apps/mobile/assets/splash.png b/apps/reader/apps/mobile/assets/splash.png
deleted file mode 100644
index 0e89705a9..000000000
Binary files a/apps/reader/apps/mobile/assets/splash.png and /dev/null differ
diff --git a/apps/reader/apps/mobile/babel.config.js b/apps/reader/apps/mobile/babel.config.js
deleted file mode 100644
index f31ca0f87..000000000
--- a/apps/reader/apps/mobile/babel.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-module.exports = function (api) {
- api.cache(true);
- let plugins = [];
-
- return {
- presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
-
- plugins,
- };
-};
diff --git a/apps/reader/apps/mobile/cesconfig.jsonc b/apps/reader/apps/mobile/cesconfig.jsonc
deleted file mode 100644
index 4f20b5a1b..000000000
--- a/apps/reader/apps/mobile/cesconfig.jsonc
+++ /dev/null
@@ -1,46 +0,0 @@
-// This is an optional configuration file used primarily for debugging purposes when reporting issues.
-// It is safe to delete this file as it does not affect the functionality of your application.
-{
- "cesVersion": "2.18.6",
- "projectName": "reader",
- "packages": [
- {
- "name": "expo-router",
- "type": "navigation",
- "options": {
- "type": "tabs"
- }
- },
- {
- "name": "nativewind",
- "type": "styling"
- },
- {
- "name": "zustand",
- "type": "state-management"
- },
- {
- "name": "supabase",
- "type": "authentication"
- }
- ],
- "flags": {
- "noGit": false,
- "noInstall": false,
- "overwrite": false,
- "importAlias": true,
- "packageManager": "npm",
- "eas": true,
- "publish": false
- },
- "packageManager": {
- "type": "npm",
- "version": "10.8.2"
- },
- "os": {
- "type": "Darwin",
- "platform": "darwin",
- "arch": "arm64",
- "kernelVersion": "24.1.0"
- }
-}
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/components/ActionMenu.tsx b/apps/reader/apps/mobile/components/ActionMenu.tsx
deleted file mode 100644
index 0aa6e064c..000000000
--- a/apps/reader/apps/mobile/components/ActionMenu.tsx
+++ /dev/null
@@ -1,189 +0,0 @@
-import React from 'react';
-import {
- Platform,
- ActionSheetIOS,
- Modal,
- View,
- Text,
- Pressable,
- StyleSheet,
- FlatList,
-} from 'react-native';
-import { Ionicons } from '@expo/vector-icons';
-import { useTheme } from '~/hooks/useTheme';
-
-interface ActionMenuOption {
- title: string;
- systemIcon?: string;
- icon?: keyof typeof Ionicons.glyphMap;
- destructive?: boolean;
- disabled?: boolean;
-}
-
-interface ActionMenuProps {
- options: ActionMenuOption[];
- onSelect: (index: number) => void;
- children: React.ReactElement;
- title?: string;
- message?: string;
-}
-
-export function ActionMenu({ options, onSelect, children, title, message }: ActionMenuProps) {
- const [visible, setVisible] = React.useState(false);
- const { colors } = useTheme();
-
- const iconMap: Record = {
- 'doc.text': 'document-text-outline',
- 'play.circle': 'play-circle-outline',
- 'square.and.arrow.up': 'share-outline',
- tag: 'pricetag-outline',
- trash: 'trash-outline',
- };
-
- const showActionSheet = () => {
- if (Platform.OS === 'ios') {
- const optionTitles = options.map((opt) => opt.title);
- const destructiveButtonIndex = options.findIndex((opt) => opt.destructive);
- const disabledButtonIndices = options
- .map((opt, idx) => (opt.disabled ? idx : -1))
- .filter((idx) => idx !== -1);
-
- ActionSheetIOS.showActionSheetWithOptions(
- {
- options: [...optionTitles, 'Abbrechen'],
- cancelButtonIndex: optionTitles.length,
- destructiveButtonIndex: destructiveButtonIndex >= 0 ? destructiveButtonIndex : undefined,
- disabledButtonIndices,
- title,
- message,
- },
- (buttonIndex) => {
- if (buttonIndex !== optionTitles.length) {
- onSelect(buttonIndex);
- }
- }
- );
- } else {
- setVisible(true);
- }
- };
-
- const handleSelect = (index: number) => {
- setVisible(false);
- setTimeout(() => onSelect(index), 100);
- };
-
- const renderOption = ({ item, index }: { item: ActionMenuOption; index: number }) => {
- const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
- const isDisabled = item.disabled;
- const isDestructive = item.destructive;
-
- return (
- !isDisabled && handleSelect(index)}
- disabled={isDisabled}
- className={`flex-row items-center px-4 py-4`}
- style={({ pressed }) => ({
- backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
- opacity: isDisabled ? 0.5 : 1,
- })}
- >
- {iconName && (
-
- )}
-
- {item.title}
-
-
- );
- };
-
- return (
- <>
- {React.cloneElement(children, {
- onLongPress: showActionSheet,
- delayLongPress: 500,
- } as any)}
-
- {Platform.OS !== 'ios' && (
- setVisible(false)}
- >
- setVisible(false)}>
-
-
-
-
- {(title || message) && (
-
- {title && (
- {title}
- )}
- {message && (
-
- {message}
-
- )}
-
- )}
-
- index.toString()}
- scrollEnabled={false}
- ItemSeparatorComponent={() => }
- />
-
-
- setVisible(false)}
- className="py-4"
- style={({ pressed }) => ({
- backgroundColor: pressed ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
- })}
- >
- Abbrechen
-
-
-
-
-
-
- )}
- >
- );
-}
-
-const styles = StyleSheet.create({
- backdrop: {
- ...StyleSheet.absoluteFillObject,
- backgroundColor: 'rgba(0, 0, 0, 0.4)',
- },
- container: {
- flex: 1,
- justifyContent: 'flex-end',
- },
- menu: {
- maxHeight: '80%',
- ...Platform.select({
- ios: {
- // @ts-ignore - React Native Web supports boxShadow
- boxShadow: '0px -2px 8px rgba(0, 0, 0, 0.1)',
- },
- android: {
- elevation: 16,
- },
- }),
- },
-});
diff --git a/apps/reader/apps/mobile/components/AudioPlayer.tsx b/apps/reader/apps/mobile/components/AudioPlayer.tsx
deleted file mode 100644
index bf2fa982f..000000000
--- a/apps/reader/apps/mobile/components/AudioPlayer.tsx
+++ /dev/null
@@ -1,489 +0,0 @@
-import React, { useState, useEffect, useRef, useMemo } from 'react';
-import {
- View,
- Text,
- Pressable,
- ActivityIndicator,
- Alert,
- Animated,
- ScrollView,
-} from 'react-native';
-import { Ionicons } from '@expo/vector-icons';
-import { useAudio } from '~/hooks/useAudio';
-import { Text as TextType, AudioVersion } from '~/types/database';
-import { useStore } from '~/store/store';
-import { useTheme } from '~/hooks/useTheme';
-import { Dropdown } from '~/components/dropdown';
-import {
- Voice,
- ALL_VOICES,
- getVoiceById,
- GERMAN_VOICES,
- PROVIDER_LABELS,
- QUALITY_LABELS,
-} from '~/constants/voices';
-import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
-
-interface AudioPlayerProps {
- text: TextType;
- onAudioGenerated?: () => void;
-}
-
-export const AudioPlayer: React.FC = ({ text, onAudioGenerated }) => {
- const [isGenerating, setIsGenerating] = useState(false);
- const [showSpeedControl, setShowSpeedControl] = useState(false);
- const [selectedVoice, setSelectedVoice] = useState('');
- const [showVersions, setShowVersions] = useState(false);
- const progressBarRef = useRef(null);
- const pulseAnim = useRef(new Animated.Value(1)).current;
-
- const { settings, updateSettings } = useStore();
- const { colors } = useTheme();
-
- // Use useMemo to prevent re-migration on every render
- const migratedData = useMemo(() => migrateAudioData(text.data), [text.data]);
- const audioVersions = migratedData.audioVersions || [];
- const currentVersion = useMemo(() => getCurrentAudioVersion(migratedData), [migratedData]);
-
- // Initialize selectedVersionId with current version
- const [selectedVersionId, setSelectedVersionId] = useState(currentVersion?.id || '');
-
- // Initialize selected voice
- useEffect(() => {
- setSelectedVoice(settings.voice);
- }, [settings.voice]);
-
- const {
- audioState,
- generationProgress,
- generateAudio,
- playAudio,
- pauseAudio,
- resumeAudio,
- stopAudio,
- seekTo,
- seekForward,
- seekBackward,
- setPlaybackSpeed,
- clearCache,
- } = useAudio();
-
- // Pulsating animation for loading state
- useEffect(() => {
- if (audioState.isLoading) {
- Animated.loop(
- Animated.sequence([
- Animated.timing(pulseAnim, {
- toValue: 1.2,
- duration: 600,
- useNativeDriver: true,
- }),
- Animated.timing(pulseAnim, {
- toValue: 1,
- duration: 600,
- useNativeDriver: true,
- }),
- ])
- ).start();
- } else {
- pulseAnim.setValue(1);
- }
- }, [audioState.isLoading, pulseAnim]);
-
- const handleGenerateAudio = async () => {
- try {
- setIsGenerating(true);
-
- await generateAudio(text.id, text.content, selectedVoice, settings.speed, text);
-
- onAudioGenerated?.();
-
- Alert.alert(
- 'Audio generiert!',
- 'Das Audio wurde erfolgreich generiert und ist jetzt verfügbar.'
- );
- } catch (error) {
- Alert.alert(
- 'Fehler',
- error instanceof Error ? error.message : 'Fehler beim Generieren des Audios'
- );
- } finally {
- setIsGenerating(false);
- }
- };
-
- const handleVoiceChange = (newVoice: string) => {
- setSelectedVoice(newVoice);
- // Update the global settings
- updateSettings({ voice: newVoice });
- };
-
- const handlePlayPause = async () => {
- if (!selectedVersion?.chunks) return;
-
- try {
- if (audioState.isPlaying) {
- await pauseAudio();
- } else if (audioState.sound) {
- await resumeAudio();
- } else {
- // Play directly from Supabase Storage
- await playAudio(text.id, selectedVersion.chunks, text.data.tts?.lastPosition || 0);
- }
- } catch (error) {
- Alert.alert(
- 'Wiedergabe-Fehler',
- error instanceof Error ? error.message : 'Fehler beim Abspielen des Audios'
- );
- }
- };
-
- const handleStop = async () => {
- await stopAudio();
- };
-
- const formatTime = (milliseconds: number): string => {
- const totalSeconds = Math.floor(milliseconds / 1000);
- const minutes = Math.floor(totalSeconds / 60);
- const seconds = totalSeconds % 60;
- return `${minutes}:${seconds.toString().padStart(2, '0')}`;
- };
-
- const formatSize = (bytes: number): string => {
- const mb = bytes / (1024 * 1024);
- return `${mb.toFixed(1)} MB`;
- };
-
- const speedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
-
- const handleSpeedChange = async (speed: number) => {
- await setPlaybackSpeed(speed);
- setShowSpeedControl(false);
- };
-
- // Use duration from audio state if available, otherwise calculate from chunks
- const totalDuration =
- audioState.duration ||
- (selectedVersion?.chunks
- ? selectedVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000
- : 0);
-
- // Handle progress bar press
- const handleProgressPress = async (event: any) => {
- if (progressBarRef.current && totalDuration > 0) {
- progressBarRef.current.measure(async (x, y, width, height, pageX, pageY) => {
- const touchX = event.nativeEvent.pageX - pageX;
- const progress = Math.max(0, Math.min(1, touchX / width));
- const newPosition = progress * totalDuration;
-
- // If audio hasn't been started yet, start it at the desired position
- if (!audioState.sound) {
- await playAudio(text.id, text.data.audio!.chunks, newPosition);
- } else {
- await seekTo(newPosition);
- }
- });
- }
- };
-
- // Get the selected audio version
- const selectedVersion = audioVersions.find((v) => v.id === selectedVersionId) || currentVersion;
- const hasAudio = selectedVersion && selectedVersion.chunks.length > 0;
-
- return (
-
- {/* Voice selection and generate button - always visible */}
-
- Sprachauswahl
- {
- const provider = voice.provider;
- const quality = voice.quality;
- if (!groups[provider]) {
- groups[provider] = {};
- }
- if (!groups[provider][quality]) {
- groups[provider][quality] = [];
- }
- groups[provider][quality].push(voice);
- return groups;
- },
- {} as Record>
- )
- ).map(([provider, qualityGroups]) => ({
- title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
- options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
- voices.map((voice) => ({
- label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
- value: voice.value,
- }))
- ),
- }))}
- />
-
-
- {isGenerating ? (
-
-
-
- {generationProgress?.currentChunk || 'Generiere Audio...'}
-
-
- ) : (
-
-
-
- {hasAudio ? 'Audio neu generieren' : 'Audio generieren'}
-
-
- )}
-
-
- {generationProgress && (
-
-
-
-
-
- {generationProgress.chunksCompleted} / {generationProgress.totalChunks} Chunks
-
-
- )}
-
-
- {/* Audio versions - only shown when audio exists */}
- {audioVersions.length > 0 && (
-
- setShowVersions(!showVersions)}
- className="flex-row items-center justify-between"
- >
-
- Audio-Versionen ({audioVersions.length})
-
-
-
-
- {showVersions && (
-
- {audioVersions.map((version) => {
- const voice = getVoiceById(version.settings.voice);
- const isActive = version.id === selectedVersionId;
- const date = new Date(version.createdAt);
-
- return (
- setSelectedVersionId(version.id)}
- className={`mb-2 rounded-lg p-3 ${
- isActive ? 'bg-blue-600' : colors.surfaceSecondary
- }`}
- >
-
-
-
- {date.toLocaleDateString('de-DE', {
- day: '2-digit',
- month: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- })}
-
-
- {voice?.label || version.settings.voice} • {version.settings.speed}x
-
-
-
- {isActive && Aktiv}
-
-
-
-
- );
- })}
-
- )}
-
- )}
-
- {/* Audio player - only shown when audio exists */}
- {hasAudio && (
-
- {/* closing tag moved to end */}
- {/* Progress bar and time info - full width */}
-
- {/* Progress Bar with touch gestures */}
-
-
- 0
- ? `${(audioState.currentPosition / totalDuration) * 100}%`
- : '0%',
- }}
- />
- {/* Scrubber indicator */}
- {totalDuration > 0 && (
-
-
-
- )}
-
-
-
- {/* Time display */}
-
-
- {formatTime(audioState.currentPosition)}
-
- {formatTime(totalDuration)}
-
-
-
- {/* Controls row */}
-
- {/* Stop button */}
-
-
-
-
- {/* Backward 15s button */}
- seekBackward(15)}
- disabled={audioState.isLoading || !audioState.sound}
- className={`rounded-full ${colors.surfaceSecondary} mr-2 p-2`}
- >
-
-
-
- 15
-
-
-
-
- {/* Play/Pause button */}
-
-
- {audioState.isLoading ? (
-
- ) : (
-
- )}
-
-
-
- {/* Forward 15s button */}
- seekForward(15)}
- disabled={audioState.isLoading || !audioState.sound}
- className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}
- >
-
-
-
- 15
-
-
-
-
- {/* Speed control button */}
- setShowSpeedControl(!showSpeedControl)}
- className={`rounded-full ${colors.surfaceSecondary} px-3 py-1.5`}
- >
-
- {audioState.playbackRate}x
-
-
-
-
- {/* Speed options dropdown */}
- {showSpeedControl && (
-
-
- {speedOptions.map((speed) => (
- handleSpeedChange(speed)}
- className={`mx-1 rounded px-3 py-1 ${
- audioState.playbackRate === speed ? colors.primary : ''
- }`}
- >
-
- {speed}x
-
-
- ))}
-
-
- )}
-
- )}
-
- );
-};
diff --git a/apps/reader/apps/mobile/components/Button.tsx b/apps/reader/apps/mobile/components/Button.tsx
deleted file mode 100644
index 3b93eff8a..000000000
--- a/apps/reader/apps/mobile/components/Button.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-import React from 'react';
-import { Pressable, PressableProps, ActivityIndicator, View } from 'react-native';
-import { Icon, IconName } from './Icon';
-import { Text } from './Text';
-
-export type ButtonVariant =
- | 'primary'
- | 'secondary'
- | 'outline'
- | 'ghost'
- | 'link'
- | 'destructive'
- | 'success'
- | 'warning';
-
-export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
-
-interface ButtonProps extends Omit {
- variant?: ButtonVariant;
- size?: ButtonSize;
- icon?: IconName;
- iconPosition?: 'left' | 'right';
- loading?: boolean;
- disabled?: boolean;
- fullWidth?: boolean;
- className?: string;
- children?: React.ReactNode;
-}
-
-export const Button: React.FC = ({
- variant = 'primary',
- size = 'md',
- icon,
- iconPosition = 'left',
- loading = false,
- disabled = false,
- fullWidth = false,
- className,
- children,
- ...props
-}) => {
- const isDisabled = disabled || loading;
-
- // Get variant styles
- const getVariantClasses = () => {
- switch (variant) {
- case 'primary':
- return 'bg-blue-600 active:bg-blue-700';
- case 'secondary':
- return 'bg-gray-600 active:bg-gray-700';
- case 'outline':
- return 'border border-gray-300 bg-white active:bg-gray-50';
- case 'ghost':
- return 'bg-transparent active:bg-gray-100';
- case 'link':
- return 'bg-transparent';
- case 'destructive':
- return 'bg-red-600 active:bg-red-700';
- case 'success':
- return 'bg-green-600 active:bg-green-700';
- case 'warning':
- return 'bg-yellow-600 active:bg-yellow-700';
- default:
- return 'bg-blue-600 active:bg-blue-700';
- }
- };
-
- // Get size styles
- const getSizeClasses = () => {
- switch (size) {
- case 'xs':
- return 'px-2 py-1';
- case 'sm':
- return 'px-3 py-2';
- case 'md':
- return 'px-4 py-3';
- case 'lg':
- return 'px-6 py-4';
- case 'xl':
- return 'px-8 py-5';
- default:
- return 'px-4 py-3';
- }
- };
-
- // Get text color
- const getTextColor = () => {
- if (isDisabled) return 'muted';
- switch (variant) {
- case 'primary':
- case 'secondary':
- case 'destructive':
- case 'success':
- case 'warning':
- return 'white';
- case 'outline':
- case 'ghost':
- return 'gray';
- case 'link':
- return 'primary';
- default:
- return 'white';
- }
- };
-
- // Get icon color
- const getIconColor = () => {
- if (isDisabled) return '#9CA3AF';
- switch (variant) {
- case 'primary':
- case 'secondary':
- case 'destructive':
- case 'success':
- case 'warning':
- return '#FFFFFF';
- case 'outline':
- case 'ghost':
- return '#6B7280';
- case 'link':
- return '#2563EB';
- default:
- return '#FFFFFF';
- }
- };
-
- // Get icon size
- const getIconSize = () => {
- switch (size) {
- case 'xs':
- return 14;
- case 'sm':
- return 16;
- case 'md':
- return 18;
- case 'lg':
- return 20;
- case 'xl':
- return 24;
- default:
- return 18;
- }
- };
-
- // Get text variant
- const getTextVariant = () => {
- switch (size) {
- case 'xs':
- case 'sm':
- return 'buttonSmall';
- default:
- return 'button';
- }
- };
-
- const renderContent = () => {
- if (loading) {
- return ;
- }
-
- const iconElement = icon ? (
-
- ) : null;
-
- const textElement = children ? (
-
- {children}
-
- ) : null;
-
- if (!icon && !children) {
- return null;
- }
-
- if (icon && !children) {
- return iconElement;
- }
-
- if (!icon && children) {
- return textElement;
- }
-
- return (
-
- {iconPosition === 'left' && iconElement}
- {textElement}
- {iconPosition === 'right' && iconElement}
-
- );
- };
-
- const buttonClasses = [
- 'rounded-lg items-center justify-center',
- getSizeClasses(),
- getVariantClasses(),
- fullWidth ? 'w-full' : '',
- isDisabled ? 'opacity-50' : '',
- className,
- ]
- .filter(Boolean)
- .join(' ');
-
- return (
-
- {renderContent()}
-
- );
-};
diff --git a/apps/reader/apps/mobile/components/ContextMenu.tsx b/apps/reader/apps/mobile/components/ContextMenu.tsx
deleted file mode 100644
index 949a10d2e..000000000
--- a/apps/reader/apps/mobile/components/ContextMenu.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import React, { useState, useRef } from 'react';
-import {
- Modal,
- View,
- Text,
- Pressable,
- Dimensions,
- Platform,
- StyleSheet,
- FlatList,
-} from 'react-native';
-import { Ionicons } from '@expo/vector-icons';
-import { useTheme } from '~/hooks/useTheme';
-
-interface ContextMenuAction {
- title: string;
- systemIcon?: string;
- icon?: keyof typeof Ionicons.glyphMap;
- destructive?: boolean;
- disabled?: boolean;
-}
-
-interface ContextMenuProps {
- actions: ContextMenuAction[];
- onPress: (index: number) => void;
- children: React.ReactElement;
-}
-
-export function ContextMenu({ actions, onPress, children }: ContextMenuProps) {
- const [visible, setVisible] = useState(false);
- const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
- const childRef = useRef(null);
- const { colors } = useTheme();
-
- const handleLongPress = () => {
- childRef.current?.measure((x, y, width, height, pageX, pageY) => {
- const screenHeight = Dimensions.get('window').height;
- const menuHeight = actions.length * 50 + 20; // Approximate menu height
-
- // Position menu above or below the pressed item based on available space
- const posY = pageY + height + menuHeight > screenHeight ? pageY - menuHeight : pageY + height;
-
- setMenuPosition({ x: pageX, y: posY });
- setVisible(true);
- });
- };
-
- const handleActionPress = (index: number) => {
- setVisible(false);
- // Small delay to allow modal to close before action
- setTimeout(() => onPress(index), 100);
- };
-
- const iconMap: Record = {
- 'doc.text': 'document-text-outline',
- 'play.circle': 'play-circle-outline',
- 'square.and.arrow.up': 'share-outline',
- tag: 'pricetag-outline',
- trash: 'trash-outline',
- };
-
- const renderAction = ({ item, index }: { item: ContextMenuAction; index: number }) => {
- const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
- const isDisabled = item.disabled;
- const isDestructive = item.destructive;
-
- return (
- !isDisabled && handleActionPress(index)}
- disabled={isDisabled}
- className={`flex-row items-center px-4 py-3 ${
- index < actions.length - 1 ? `border-b ${colors.border}` : ''
- }`}
- style={({ pressed }) => ({
- backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
- opacity: isDisabled ? 0.5 : 1,
- })}
- >
- {iconName && (
-
- )}
-
- {item.title}
-
-
- );
- };
-
- return (
- <>
-
- {React.cloneElement(children, {
- onLongPress: handleLongPress,
- delayLongPress: 500,
- } as any)}
-
-
- setVisible(false)}
- >
- setVisible(false)}>
-
-
-
- index.toString()}
- scrollEnabled={false}
- />
-
-
-
- >
- );
-}
-
-const styles = StyleSheet.create({
- backdrop: {
- ...StyleSheet.absoluteFillObject,
- },
- menu: {
- position: 'absolute',
- borderRadius: 12,
- overflow: 'hidden',
- ...Platform.select({
- ios: {
- // @ts-ignore - React Native Web supports boxShadow
- boxShadow: '0px 2px 10px rgba(0, 0, 0, 0.25)',
- },
- android: {
- elevation: 8,
- },
- }),
- },
-});
diff --git a/apps/reader/apps/mobile/components/EditScreenInfo.tsx b/apps/reader/apps/mobile/components/EditScreenInfo.tsx
deleted file mode 100644
index f933b864f..000000000
--- a/apps/reader/apps/mobile/components/EditScreenInfo.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Text, View } from 'react-native';
-
-export const EditScreenInfo = ({ path }: { path: string }) => {
- const title = 'Open up the code for this screen:';
- const description =
- 'Change any of the text, save the file, and your app will automatically update.';
-
- return (
-
-
- {title}
-
- {path}
-
- {description}
-
-
- );
-};
-
-const styles = {
- codeHighlightContainer: `rounded-md px-1`,
- getStartedContainer: `items-center mx-12`,
- getStartedText: `text-lg leading-6 text-center`,
- helpContainer: `items-center mx-5 mt-4`,
- helpLink: `py-4`,
- helpLinkText: `text-center`,
- homeScreenFilename: `my-2`,
-};
diff --git a/apps/reader/apps/mobile/components/FloatingActionButton.tsx b/apps/reader/apps/mobile/components/FloatingActionButton.tsx
deleted file mode 100644
index a13de8154..000000000
--- a/apps/reader/apps/mobile/components/FloatingActionButton.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import React from 'react';
-import { Pressable, Text, ActivityIndicator, ViewStyle } from 'react-native';
-import { useTheme } from '~/hooks/useTheme';
-
-interface FloatingActionButtonProps {
- onPress: () => void;
- icon: string;
- label: string;
- disabled?: boolean;
- loading?: boolean;
- style?: ViewStyle;
-}
-
-export function FloatingActionButton({
- onPress,
- icon,
- label,
- disabled = false,
- loading = false,
- style,
-}: FloatingActionButtonProps) {
- const { colors } = useTheme();
-
- return (
-
- {loading ? (
-
- ) : (
- <>
- {icon}
- {label}
- >
- )}
-
- );
-}
diff --git a/apps/reader/apps/mobile/components/Header.tsx b/apps/reader/apps/mobile/components/Header.tsx
deleted file mode 100644
index ce420d4c5..000000000
--- a/apps/reader/apps/mobile/components/Header.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import React from 'react';
-import { View, Pressable, Platform, StatusBar } from 'react-native';
-import { router } from 'expo-router';
-import { Icon } from './Icon';
-import { Text } from './Text';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { useTheme } from '~/hooks/useTheme';
-
-interface HeaderProps {
- title?: string;
- showBackButton?: boolean;
- rightComponent?: React.ReactNode;
- onBackPress?: () => void;
- backgroundColor?: string;
- textColor?: string;
-}
-
-export const Header: React.FC = ({
- title,
- showBackButton = true,
- rightComponent,
- onBackPress,
- backgroundColor,
- textColor,
-}) => {
- const insets = useSafeAreaInsets();
- const { isDark, colors } = useTheme();
-
- const handleBackPress = () => {
- if (onBackPress) {
- onBackPress();
- } else {
- router.back();
- }
- };
-
- // Use theme colors if not explicitly provided
- const headerBackgroundColor = backgroundColor || (isDark ? colors.tabBarBackground : '#ffffff');
- const headerTextColor = textColor || (isDark ? '#ffffff' : '#000000');
- const borderColor = isDark ? colors.tabBarBorder : '#e5e7eb';
-
- return (
-
-
-
-
- {/* Left side - Back button */}
-
- {showBackButton && (
-
-
-
- )}
-
-
- {/* Center - Title */}
-
- {title && (
-
- {title}
-
- )}
-
-
- {/* Right side - Custom component */}
- {rightComponent}
-
-
- );
-};
diff --git a/apps/reader/apps/mobile/components/Icon.tsx b/apps/reader/apps/mobile/components/Icon.tsx
deleted file mode 100644
index d03f5a12c..000000000
--- a/apps/reader/apps/mobile/components/Icon.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import React from 'react';
-import { View } from 'react-native';
-import { Ionicons } from '@expo/vector-icons';
-
-export type IconName =
- | 'add'
- | 'delete'
- | 'edit'
- | 'save'
- | 'close'
- | 'back'
- | 'play'
- | 'pause'
- | 'stop'
- | 'refresh'
- | 'settings'
- | 'logout'
- | 'eye'
- | 'eye-off'
- | 'heart'
- | 'heart-outline'
- | 'tag'
- | 'filter'
- | 'search'
- | 'download'
- | 'share'
- | 'volume-high'
- | 'volume-low'
- | 'volume-mute'
- | 'fast-forward'
- | 'rewind'
- | 'skip-forward'
- | 'skip-backward'
- | 'checkmark'
- | 'close-circle'
- | 'alert-circle'
- | 'information-circle'
- | 'chevron-down'
- | 'chevron-up'
- | 'chevron-left'
- | 'chevron-right'
- | 'arrow-back'
- | 'arrow-forward'
- | 'home'
- | 'library'
- | 'person'
- | 'menu'
- | 'more-horizontal'
- | 'more-vertical'
- | 'replay-15'
- | 'forward-15'
- | 'play-circle'
- | 'pause-circle'
- | 'mic-circle';
-
-interface IconProps {
- name: IconName;
- size?: number;
- color?: string;
- className?: string;
-}
-
-const iconMapping: Record = {
- add: 'add',
- delete: 'trash',
- edit: 'pencil',
- save: 'save',
- close: 'close',
- back: 'arrow-back',
- play: 'play',
- pause: 'pause',
- stop: 'stop',
- refresh: 'refresh',
- settings: 'settings',
- logout: 'log-out',
- eye: 'eye',
- 'eye-off': 'eye-off',
- heart: 'heart',
- 'heart-outline': 'heart-outline',
- tag: 'pricetag',
- filter: 'filter',
- search: 'search',
- download: 'download',
- share: 'share',
- 'volume-high': 'volume-high',
- 'volume-low': 'volume-low',
- 'volume-mute': 'volume-mute',
- 'fast-forward': 'play-forward',
- rewind: 'play-back',
- 'skip-forward': 'play-skip-forward',
- 'skip-backward': 'play-skip-back',
- checkmark: 'checkmark',
- 'close-circle': 'close-circle',
- 'alert-circle': 'alert-circle',
- 'information-circle': 'information-circle',
- 'chevron-down': 'chevron-down',
- 'chevron-up': 'chevron-up',
- 'chevron-left': 'chevron-back',
- 'chevron-right': 'chevron-forward',
- 'arrow-back': 'arrow-back',
- 'arrow-forward': 'arrow-forward',
- home: 'home',
- library: 'library',
- person: 'person',
- menu: 'menu',
- 'more-horizontal': 'ellipsis-horizontal',
- 'more-vertical': 'ellipsis-vertical',
- 'replay-15': 'refresh-circle',
- 'forward-15': 'add-circle',
- 'play-circle': 'play-circle',
- 'pause-circle': 'pause-circle',
- 'mic-circle': 'mic-circle',
-};
-
-export const Icon: React.FC = ({ name, size = 24, color = '#000000', className }) => {
- const ionIconName = iconMapping[name];
-
- if (!ionIconName) {
- console.warn(`Icon "${name}" not found in iconMapping`);
- return null;
- }
-
- return (
-
-
-
- );
-};
diff --git a/apps/reader/apps/mobile/components/MinimalAudioPlayer.tsx b/apps/reader/apps/mobile/components/MinimalAudioPlayer.tsx
deleted file mode 100644
index 9a23926bc..000000000
--- a/apps/reader/apps/mobile/components/MinimalAudioPlayer.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { View, Pressable, ActivityIndicator } from 'react-native';
-import { Icon } from '~/components/Icon';
-import { useAudio } from '~/hooks/useAudio';
-import { Text as TextType } from '~/types/database';
-import { useStore } from '~/store/store';
-import { useTheme } from '~/hooks/useTheme';
-import { getCurrentAudioVersion, migrateAudioData } from '~/utils/audioMigration';
-
-interface MinimalAudioPlayerProps {
- text: TextType;
-}
-
-export const MinimalAudioPlayer: React.FC = ({ text }) => {
- const [isGenerating, setIsGenerating] = useState(false);
- const { currentTextId } = useStore();
- const { colors } = useTheme();
-
- const { audioState, generateAudio, playAudio, pauseAudio, resumeAudio, stopAudio } = useAudio();
-
- // Check if this text is currently playing
- const isCurrentText = currentTextId === text.id;
- const isPlaying = isCurrentText && audioState.isPlaying;
- const isLoading = isCurrentText && audioState.isLoading;
-
- // Get audio version
- const migratedData = migrateAudioData(text.data);
- const currentVersion = getCurrentAudioVersion(migratedData);
- const hasAudio = currentVersion && currentVersion.chunks.length > 0;
-
- // Stop audio when component unmounts or text changes
- useEffect(() => {
- return () => {
- if (isCurrentText) {
- stopAudio();
- }
- };
- }, [isCurrentText, stopAudio]);
-
- const handlePlayPause = async () => {
- if (!hasAudio) {
- // Generate audio if not available
- try {
- setIsGenerating(true);
- const { settings } = useStore.getState();
- await generateAudio(text.id, text.content, settings.voice, settings.speed, text);
- } catch (error) {
- console.error('Error generating audio:', error);
- } finally {
- setIsGenerating(false);
- }
- return;
- }
-
- try {
- if (isPlaying) {
- await pauseAudio();
- } else if (isCurrentText && audioState.sound) {
- await resumeAudio();
- } else {
- // Stop any other playing audio and start this one
- if (currentTextId && currentTextId !== text.id) {
- await stopAudio();
- }
- await playAudio(text.id, currentVersion.chunks, 0);
- }
- } catch (error) {
- console.error('Error playing audio:', error);
- }
- };
-
- return (
-
- {isLoading || isGenerating ? (
-
- ) : (
-
- )}
-
- );
-};
diff --git a/apps/reader/apps/mobile/components/ScreenContent.tsx b/apps/reader/apps/mobile/components/ScreenContent.tsx
deleted file mode 100644
index c72736d06..000000000
--- a/apps/reader/apps/mobile/components/ScreenContent.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Text, View } from 'react-native';
-
-import { EditScreenInfo } from './EditScreenInfo';
-
-type ScreenContentProps = {
- title: string;
- path: string;
- children?: React.ReactNode;
-};
-
-export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
- return (
-
- {title}
-
-
- {children}
-
- );
-};
-const styles = {
- container: `items-center flex-1 justify-center`,
- separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
- title: `text-xl font-bold`,
-};
diff --git a/apps/reader/apps/mobile/components/TabBarIcon.tsx b/apps/reader/apps/mobile/components/TabBarIcon.tsx
deleted file mode 100644
index 05c3c86f1..000000000
--- a/apps/reader/apps/mobile/components/TabBarIcon.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import FontAwesome from '@expo/vector-icons/FontAwesome';
-import { StyleSheet } from 'react-native';
-
-export const TabBarIcon = (props: {
- name: React.ComponentProps['name'];
- color: string;
-}) => {
- return ;
-};
-
-export const styles = StyleSheet.create({
- tabBarIcon: {
- marginBottom: -3,
- },
-});
diff --git a/apps/reader/apps/mobile/components/TagFilter.tsx b/apps/reader/apps/mobile/components/TagFilter.tsx
deleted file mode 100644
index d81147785..000000000
--- a/apps/reader/apps/mobile/components/TagFilter.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react';
-import { View, Text, ScrollView, Pressable } from 'react-native';
-import { useTexts } from '~/hooks/useTexts';
-import { useStore } from '~/store/store';
-import { useTheme } from '~/hooks/useTheme';
-
-export const TagFilter: React.FC = () => {
- const { getAllTags } = useTexts();
- const { selectedTags, toggleTag, clearTags } = useStore();
- const { colors } = useTheme();
-
- const allTags = getAllTags();
-
- if (allTags.length === 0) {
- return null;
- }
-
- return (
-
-
- Tags filtern:
- {selectedTags.length > 0 && (
-
- Alle entfernen
-
- )}
-
-
-
- {allTags.map((tag) => {
- const isSelected = selectedTags.includes(tag);
- return (
- toggleTag(tag)}
- className={`mr-2 rounded-full border px-3 py-1 ${
- isSelected
- ? `border-blue-500 ${colors.primaryLight}`
- : `${colors.borderSecondary} ${colors.surfaceSecondary}`
- }`}
- >
-
- {tag}
-
-
- );
- })}
-
-
- );
-};
diff --git a/apps/reader/apps/mobile/components/Text.tsx b/apps/reader/apps/mobile/components/Text.tsx
deleted file mode 100644
index a8fed231a..000000000
--- a/apps/reader/apps/mobile/components/Text.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-import React from 'react';
-import { Text as RNText, TextProps as RNTextProps } from 'react-native';
-import { useTheme } from '~/hooks/useTheme';
-
-export type TextVariant =
- | 'h1'
- | 'h2'
- | 'h3'
- | 'h4'
- | 'h5'
- | 'h6'
- | 'body'
- | 'bodyLarge'
- | 'bodySmall'
- | 'caption'
- | 'label'
- | 'labelLarge'
- | 'labelSmall'
- | 'button'
- | 'buttonSmall'
- | 'overline'
- | 'subtitle1'
- | 'subtitle2';
-
-export type TextColor =
- | 'primary'
- | 'secondary'
- | 'tertiary'
- | 'accent'
- | 'error'
- | 'warning'
- | 'success'
- | 'info'
- | 'white'
- | 'black'
- | 'gray'
- | 'muted'
- | 'red'
- | 'blue'
- | 'green'
- | 'yellow'
- | 'purple'
- | 'pink'
- | 'indigo'
- | 'cyan'
- | 'orange'
- | 'inherit';
-
-interface TextComponentProps extends RNTextProps {
- variant?: TextVariant;
- color?: TextColor;
- weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
- align?: 'left' | 'center' | 'right' | 'justify';
- className?: string;
- children: React.ReactNode;
-}
-
-const variantStyles: Record = {
- h1: 'text-4xl font-bold',
- h2: 'text-3xl font-bold',
- h3: 'text-2xl font-bold',
- h4: 'text-xl font-bold',
- h5: 'text-lg font-bold',
- h6: 'text-base font-bold',
- body: 'text-base',
- bodyLarge: 'text-lg',
- bodySmall: 'text-sm',
- caption: 'text-xs',
- label: 'text-sm font-medium',
- labelLarge: 'text-base font-medium',
- labelSmall: 'text-xs font-medium',
- button: 'text-base font-semibold',
- buttonSmall: 'text-sm font-semibold',
- overline: 'text-xs font-medium uppercase tracking-wide',
- subtitle1: 'text-base font-medium',
- subtitle2: 'text-sm font-medium',
-};
-
-const colorStyles: Record = {
- primary: 'text-blue-600',
- secondary: 'text-gray-600',
- accent: 'text-purple-600',
- error: 'text-red-600',
- warning: 'text-yellow-600',
- success: 'text-green-600',
- info: 'text-blue-500',
- white: 'text-white',
- black: 'text-black',
- gray: 'text-gray-500',
- muted: 'text-gray-400',
- red: 'text-red-600',
- blue: 'text-blue-600',
- green: 'text-green-600',
- yellow: 'text-yellow-600',
- purple: 'text-purple-600',
- pink: 'text-pink-600',
- indigo: 'text-indigo-600',
- cyan: 'text-cyan-600',
- orange: 'text-orange-600',
-};
-
-const weightStyles: Record = {
- light: 'font-light',
- normal: 'font-normal',
- medium: 'font-medium',
- semibold: 'font-semibold',
- bold: 'font-bold',
-};
-
-const alignStyles: Record = {
- left: 'text-left',
- center: 'text-center',
- right: 'text-right',
- justify: 'text-justify',
-};
-
-export const Text: React.FC = ({
- variant = 'body',
- color = 'inherit',
- weight,
- align,
- className,
- children,
- ...props
-}) => {
- const { colors } = useTheme();
-
- // Map semantic colors to theme colors
- const getThemeColor = (textColor: TextColor): string => {
- switch (textColor) {
- case 'inherit':
- case 'primary':
- return colors.text;
- case 'secondary':
- return colors.textSecondary;
- case 'tertiary':
- case 'muted':
- return colors.textTertiary;
- default:
- return colorStyles[textColor] || colors.text;
- }
- };
-
- const variantClass = variantStyles[variant];
- const colorClass = getThemeColor(color);
- const weightClass = weight ? weightStyles[weight] : '';
- const alignClass = align ? alignStyles[align] : '';
-
- const combinedClassName = [variantClass, colorClass, weightClass, alignClass, className]
- .filter(Boolean)
- .join(' ');
-
- return (
-
- {children}
-
- );
-};
diff --git a/apps/reader/apps/mobile/components/TextListItem.tsx b/apps/reader/apps/mobile/components/TextListItem.tsx
deleted file mode 100644
index b76679aa9..000000000
--- a/apps/reader/apps/mobile/components/TextListItem.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import React from 'react';
-import { View, Text, Pressable } from 'react-native';
-import { router } from 'expo-router';
-import { ActionMenu } from '~/components/ActionMenu';
-import { MinimalAudioPlayer } from '~/components/MinimalAudioPlayer';
-import { Text as TextType } from '~/types/database';
-import { useTheme } from '~/hooks/useTheme';
-
-interface TextListItemProps {
- item: TextType;
- onShare: (text: TextType) => void;
- onDelete: (textId: string, title: string) => void;
- formatDate: (dateString: string) => string;
- getAudioDuration: (item: TextType) => string | null;
-}
-
-export const TextListItem: React.FC = ({
- item,
- onShare,
- onDelete,
- formatDate,
- getAudioDuration,
-}) => {
- const { colors } = useTheme();
-
- const handleMenuSelect = (index: number) => {
- switch (index) {
- case 0: // Öffnen
- router.push(`/text/${item.id}`);
- break;
- case 1: // Teilen
- onShare(item);
- break;
- case 2: // Tags bearbeiten
- router.push(`/text/${item.id}`);
- break;
- case 3: // Löschen
- onDelete(item.id, item.title);
- break;
- }
- };
-
- return (
-
- router.push(`/text/${item.id}`)}
- className={`mb-3 rounded-lg border ${colors.border} ${colors.surface} p-4 shadow-sm`}
- >
- {/* Header with title and date/duration */}
-
-
- {item.title}
-
-
- {formatDate(item.updated_at)}
- {getAudioDuration(item) && (
- <>
- •
- {getAudioDuration(item)}
- >
- )}
-
-
-
- {/* Content preview */}
-
- {item.content}
-
-
- {/* Footer with tags and audio player */}
-
-
- {item.data.tags?.map((tag, index) => (
-
- {tag}
-
- ))}
-
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/reader/apps/mobile/components/dropdown.tsx b/apps/reader/apps/mobile/components/dropdown.tsx
deleted file mode 100644
index fc802fbe4..000000000
--- a/apps/reader/apps/mobile/components/dropdown.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import React, { useState } from 'react';
-import { View, Text, TouchableOpacity, Modal, ScrollView, Pressable } from 'react-native';
-import { Ionicons } from '@expo/vector-icons';
-import { useTheme } from '~/hooks/useTheme';
-
-interface DropdownOption {
- label: string;
- value: string;
-}
-
-interface DropdownGroup {
- title: string;
- options: DropdownOption[];
-}
-
-interface DropdownProps {
- options: DropdownOption[];
- groups?: DropdownGroup[];
- value: string;
- onValueChange: (value: string) => void;
- placeholder?: string;
- disabled?: boolean;
- title?: string;
-}
-
-export function Dropdown({
- options,
- groups,
- value,
- onValueChange,
- placeholder = 'Select an option',
- disabled = false,
- title = 'Select Option',
-}: DropdownProps) {
- const [isOpen, setIsOpen] = useState(false);
- const { colors } = useTheme();
-
- // Find selected option from either flat options or groups
- const allOptions = groups ? groups.flatMap((g) => g.options) : options;
- const selectedOption = allOptions.find((opt) => opt.value === value);
-
- const handleSelect = (optionValue: string) => {
- onValueChange(optionValue);
- setIsOpen(false);
- };
-
- return (
-
- !disabled && setIsOpen(true)}
- className={`flex-row items-center justify-between rounded-lg border ${colors.border} ${colors.surface} px-4 py-3 ${
- disabled ? 'opacity-50' : ''
- }`}
- disabled={disabled}
- >
-
- {selectedOption?.label || placeholder}
-
-
-
-
- setIsOpen(false)}
- >
- setIsOpen(false)}
- >
-
- e.stopPropagation()}
- className={`max-h-[80%] rounded-xl border ${colors.border} ${colors.surface} shadow-xl`}
- >
-
-
- {title}
- setIsOpen(false)}>
-
-
-
-
-
- {groups
- ? // Render grouped options
- groups.map((group, groupIndex) => (
- 0 ? 'mt-4' : ''}>
-
- {group.title}
-
- {group.options.map((option) => (
- handleSelect(option.value)}
- className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
- option.value === value ? colors.primary : colors.surfaceSecondary
- }`}
- >
-
- {option.label}
-
-
- ))}
-
- ))
- : // Render flat options
- options.map((option) => (
- handleSelect(option.value)}
- className={`mx-2 mb-1 rounded-lg px-4 py-3 ${
- option.value === value ? colors.primary : colors.surfaceSecondary
- }`}
- >
-
- {option.label}
-
-
- ))}
-
-
-
-
-
-
- );
-}
diff --git a/apps/reader/apps/mobile/constants/voices.ts b/apps/reader/apps/mobile/constants/voices.ts
deleted file mode 100644
index c6987a62d..000000000
--- a/apps/reader/apps/mobile/constants/voices.ts
+++ /dev/null
@@ -1,386 +0,0 @@
-export type VoiceProvider = 'google' | 'elevenlabs' | 'openai';
-
-export interface Voice {
- value: string;
- label: string;
- gender: 'male' | 'female';
- quality: 'premium' | 'neural' | 'wavenet' | 'studio' | 'standard';
- language: string;
- provider: VoiceProvider;
-}
-
-export const GERMAN_VOICES: Voice[] = [
- // Note: Google Chirp HD voices (de-DE-Chirp3-HD-*) are available but not included here
- // as they require special API access and are significantly more expensive.
- // Add them if you have access: https://cloud.google.com/text-to-speech/docs/voices
-
- // Google Cloud TTS - Neural2 voices (most commonly used, good balance of quality and cost)
- {
- value: 'de-DE-Neural2-A',
- label: 'Neural2 A (Weiblich)',
- gender: 'female',
- quality: 'neural',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Neural2-B',
- label: 'Neural2 B (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Neural2-C',
- label: 'Neural2 C (Weiblich)',
- gender: 'female',
- quality: 'neural',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Neural2-D',
- label: 'Neural2 D (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Neural2-E',
- label: 'Neural2 E (Weiblich)',
- gender: 'female',
- quality: 'neural',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Neural2-F',
- label: 'Neural2 F (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'de-DE',
- provider: 'google',
- },
-
- // Google Cloud TTS - WaveNet voices (high quality, natural sounding)
- {
- value: 'de-DE-Wavenet-A',
- label: 'WaveNet A (Weiblich)',
- gender: 'female',
- quality: 'wavenet',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Wavenet-B',
- label: 'WaveNet B (Männlich)',
- gender: 'male',
- quality: 'wavenet',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Wavenet-C',
- label: 'WaveNet C (Weiblich)',
- gender: 'female',
- quality: 'wavenet',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Wavenet-D',
- label: 'WaveNet D (Männlich)',
- gender: 'male',
- quality: 'wavenet',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Wavenet-E',
- label: 'WaveNet E (Weiblich)',
- gender: 'female',
- quality: 'wavenet',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Wavenet-F',
- label: 'WaveNet F (Männlich)',
- gender: 'male',
- quality: 'wavenet',
- language: 'de-DE',
- provider: 'google',
- },
-
- // Google Cloud TTS - Studio voices (broadcast quality)
- {
- value: 'de-DE-Studio-B',
- label: 'Studio B (Männlich)',
- gender: 'male',
- quality: 'studio',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Studio-C',
- label: 'Studio C (Weiblich)',
- gender: 'female',
- quality: 'studio',
- language: 'de-DE',
- provider: 'google',
- },
-
- // Google Cloud TTS - Standard voices (basic quality, lowest cost)
- {
- value: 'de-DE-Standard-A',
- label: 'Standard A (Weiblich)',
- gender: 'female',
- quality: 'standard',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Standard-B',
- label: 'Standard B (Männlich)',
- gender: 'male',
- quality: 'standard',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Standard-C',
- label: 'Standard C (Weiblich)',
- gender: 'female',
- quality: 'standard',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Standard-D',
- label: 'Standard D (Männlich)',
- gender: 'male',
- quality: 'standard',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Standard-E',
- label: 'Standard E (Weiblich)',
- gender: 'female',
- quality: 'standard',
- language: 'de-DE',
- provider: 'google',
- },
- {
- value: 'de-DE-Standard-F',
- label: 'Standard F (Männlich)',
- gender: 'male',
- quality: 'standard',
- language: 'de-DE',
- provider: 'google',
- },
-
- // ElevenLabs voices
- {
- value: 'eleven_multilingual_v2',
- label: 'Rachel (Weiblich)',
- gender: 'female',
- quality: 'premium',
- language: 'de-DE',
- provider: 'elevenlabs',
- },
- {
- value: 'eleven_multilingual_v1',
- label: 'Adam (Männlich)',
- gender: 'male',
- quality: 'premium',
- language: 'de-DE',
- provider: 'elevenlabs',
- },
- {
- value: 'eleven_turbo_v2',
- label: 'Turbo Rachel (Weiblich) - Low Latency',
- gender: 'female',
- quality: 'premium',
- language: 'de-DE',
- provider: 'elevenlabs',
- },
- {
- value: 'eleven_monolingual_v1',
- label: 'Clyde (Männlich)',
- gender: 'male',
- quality: 'premium',
- language: 'de-DE',
- provider: 'elevenlabs',
- },
-
- // OpenAI voices
- {
- value: 'alloy',
- label: 'Alloy (Neutral)',
- gender: 'female',
- quality: 'neural',
- language: 'de-DE',
- provider: 'openai',
- },
- {
- value: 'echo',
- label: 'Echo (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'de-DE',
- provider: 'openai',
- },
- {
- value: 'fable',
- label: 'Fable (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'de-DE',
- provider: 'openai',
- },
- {
- value: 'onyx',
- label: 'Onyx (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'de-DE',
- provider: 'openai',
- },
- {
- value: 'nova',
- label: 'Nova (Weiblich)',
- gender: 'female',
- quality: 'neural',
- language: 'de-DE',
- provider: 'openai',
- },
- {
- value: 'shimmer',
- label: 'Shimmer (Weiblich)',
- gender: 'female',
- quality: 'neural',
- language: 'de-DE',
- provider: 'openai',
- },
-];
-
-export const ENGLISH_US_VOICES: Voice[] = [
- // Google Cloud TTS - Neural2 voices
- {
- value: 'en-US-Neural2-A',
- label: 'Neural2 A (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'en-US',
- provider: 'google',
- },
- {
- value: 'en-US-Neural2-C',
- label: 'Neural2 C (Weiblich)',
- gender: 'female',
- quality: 'neural',
- language: 'en-US',
- provider: 'google',
- },
- {
- value: 'en-US-Neural2-D',
- label: 'Neural2 D (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'en-US',
- provider: 'google',
- },
- {
- value: 'en-US-Neural2-E',
- label: 'Neural2 E (Weiblich)',
- gender: 'female',
- quality: 'neural',
- language: 'en-US',
- provider: 'google',
- },
-];
-
-export const ENGLISH_GB_VOICES: Voice[] = [
- // Google Cloud TTS - Neural2 voices
- {
- value: 'en-GB-Neural2-A',
- label: 'Neural2 A (Weiblich)',
- gender: 'female',
- quality: 'neural',
- language: 'en-GB',
- provider: 'google',
- },
- {
- value: 'en-GB-Neural2-B',
- label: 'Neural2 B (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'en-GB',
- provider: 'google',
- },
- {
- value: 'en-GB-Neural2-C',
- label: 'Neural2 C (Weiblich)',
- gender: 'female',
- quality: 'neural',
- language: 'en-GB',
- provider: 'google',
- },
- {
- value: 'en-GB-Neural2-D',
- label: 'Neural2 D (Männlich)',
- gender: 'male',
- quality: 'neural',
- language: 'en-GB',
- provider: 'google',
- },
-];
-
-export const ALL_VOICES = [...GERMAN_VOICES, ...ENGLISH_US_VOICES, ...ENGLISH_GB_VOICES];
-
-export const getVoicesByLanguage = (language: string): Voice[] => {
- return ALL_VOICES.filter((voice) => voice.language === language);
-};
-
-export const getVoiceById = (voiceId: string): Voice | undefined => {
- if (!voiceId) return undefined;
-
- try {
- const allVoices = [...GERMAN_VOICES, ...ENGLISH_US_VOICES, ...ENGLISH_GB_VOICES];
- return allVoices.find((voice) => voice.value === voiceId);
- } catch (error) {
- console.error('Error in getVoiceById:', error);
- return undefined;
- }
-};
-
-export const QUALITY_LABELS: Record = {
- premium: '🌟 Premium',
- neural: '🧠 Neural',
- wavenet: '🌊 WaveNet',
- studio: '🎙️ Studio',
- standard: '📢 Standard',
-};
-
-export const PROVIDER_LABELS: Record = {
- google: '🔵 Google Cloud',
- elevenlabs: '🎯 ElevenLabs',
- openai: '🤖 OpenAI',
-};
-
-// Backward compatibility: map old voice codes to new voice IDs
-export const LEGACY_VOICE_MAP: Record = {
- 'de-DE': 'de-DE-Neural2-A',
- 'en-US': 'en-US-Neural2-A',
- 'en-GB': 'en-GB-Neural2-A',
- // Also map old voice IDs that no longer exist
- 'de-DE-Neural2-G': 'de-DE-Neural2-A',
- 'de-DE-Neural2-H': 'de-DE-Neural2-B',
- 'de-DE-Wavenet-G': 'de-DE-Wavenet-A',
- 'de-DE-Wavenet-H': 'de-DE-Wavenet-B',
- 'de-DE-Standard-G': 'de-DE-Standard-A',
- 'de-DE-Standard-H': 'de-DE-Standard-B',
-};
diff --git a/apps/reader/apps/mobile/docs/browser-extension-concept.md b/apps/reader/apps/mobile/docs/browser-extension-concept.md
deleted file mode 100644
index 388988e3a..000000000
--- a/apps/reader/apps/mobile/docs/browser-extension-concept.md
+++ /dev/null
@@ -1,126 +0,0 @@
-# Browser Extension für URL-Extraktion
-
-## Konzept
-
-Eine Browser Extension kann direkt auf den gerenderten Content zugreifen, nachdem der Nutzer Cookies akzeptiert hat.
-
-## Implementation (Chrome/Safari)
-
-### Manifest.json
-
-```json
-{
- "manifest_version": 3,
- "name": "Reader App Extractor",
- "permissions": ["activeTab", "clipboardWrite"],
- "action": {
- "default_popup": "popup.html"
- },
- "content_scripts": [
- {
- "matches": [""],
- "js": ["content.js"]
- }
- ]
-}
-```
-
-### Content Script
-
-```javascript
-// content.js
-function extractArticle() {
- // Nutze Readability direkt im Browser
- const documentClone = document.cloneNode(true);
- const reader = new Readability(documentClone);
- const article = reader.parse();
-
- if (article) {
- // Sende an Reader App
- const readerUrl = `reader-app://add?title=${encodeURIComponent(article.title)}&content=${encodeURIComponent(article.content)}`;
- window.location.href = readerUrl;
- }
-}
-```
-
-### Integration in React Native
-
-```typescript
-// Deep Link Handler
-import { Linking } from 'react-native';
-
-Linking.addEventListener('url', (event) => {
- const url = new URL(event.url);
- if (url.protocol === 'reader-app:' && url.pathname === 'add') {
- const title = url.searchParams.get('title');
- const content = url.searchParams.get('content');
- // Erstelle neuen Text
- }
-});
-```
-
-## iOS Share Extension Alternative
-
-### Info.plist
-
-```xml
-NSExtension
-
- NSExtensionAttributes
-
- NSExtensionActivationRule
- SUBQUERY(extensionItems, $e, SUBQUERY($e.attachments, $a, $a.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url").@count > 0).@count > 0
-
-
-```
-
-### Share Extension Code
-
-```swift
-import MobileCoreServices
-
-class ShareViewController: UIViewController {
- override func viewDidLoad() {
- super.viewDidLoad()
-
- if let item = extensionContext?.inputItems.first as? NSExtensionItem,
- let provider = item.attachments?.first {
-
- if provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
- provider.loadItem(forTypeIdentifier: kUTTypeURL as String) { (url, error) in
- if let shareURL = url as? URL {
- // Extrahiere Content mit WKWebView
- self.extractContent(from: shareURL)
- }
- }
- }
- }
- }
-
- func extractContent(from url: URL) {
- let webView = WKWebView()
- webView.load(URLRequest(url: url))
-
- // Nach dem Laden JavaScript ausführen
- webView.evaluateJavaScript("document.body.innerText") { (result, error) in
- if let text = result as? String {
- // Speichere in App Group oder sende an App
- self.saveToApp(title: url.host ?? "Artikel", content: text, url: url.absoluteString)
- }
- }
- }
-}
-```
-
-## Vorteile
-
-1. Umgeht alle Cookie-Banner (Nutzer akzeptiert im Browser)
-2. Zugriff auf den vollständig gerenderten Content
-3. Native Integration in iOS/Android Share-Menü
-4. Kein Server-Side Rendering nötig
-
-## Nachteile
-
-1. Zusätzliche Installation erforderlich
-2. Platform-spezifische Entwicklung
-3. App Store Review Process für Extensions
diff --git a/apps/reader/apps/mobile/docs/deployment-guide.md b/apps/reader/apps/mobile/docs/deployment-guide.md
deleted file mode 100644
index 07df64489..000000000
--- a/apps/reader/apps/mobile/docs/deployment-guide.md
+++ /dev/null
@@ -1,300 +0,0 @@
-# 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
diff --git a/apps/reader/apps/mobile/docs/google-cloud-setup.md b/apps/reader/apps/mobile/docs/google-cloud-setup.md
deleted file mode 100644
index cc2618039..000000000
--- a/apps/reader/apps/mobile/docs/google-cloud-setup.md
+++ /dev/null
@@ -1,134 +0,0 @@
-# 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
diff --git a/apps/reader/apps/mobile/docs/url-extraction-options.md b/apps/reader/apps/mobile/docs/url-extraction-options.md
deleted file mode 100644
index 37f07aa5e..000000000
--- a/apps/reader/apps/mobile/docs/url-extraction-options.md
+++ /dev/null
@@ -1,149 +0,0 @@
-# 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
-}
-```
diff --git a/apps/reader/apps/mobile/eslint.config.js b/apps/reader/apps/mobile/eslint.config.js
deleted file mode 100644
index 9cfeaf657..000000000
--- a/apps/reader/apps/mobile/eslint.config.js
+++ /dev/null
@@ -1,15 +0,0 @@
-/* eslint-env node */
-const { defineConfig } = require('eslint/config');
-const expoConfig = require('eslint-config-expo/flat');
-
-module.exports = defineConfig([
- expoConfig,
- {
- ignores: ['dist/*', 'supabase/functions/**/*', '.expo/**/*'],
- },
- {
- rules: {
- 'react/display-name': 'off',
- },
- },
-]);
diff --git a/apps/reader/apps/mobile/global.css b/apps/reader/apps/mobile/global.css
deleted file mode 100644
index b5c61c956..000000000
--- a/apps/reader/apps/mobile/global.css
+++ /dev/null
@@ -1,3 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
diff --git a/apps/reader/apps/mobile/hooks/useAudio.ts b/apps/reader/apps/mobile/hooks/useAudio.ts
deleted file mode 100644
index 495a6ccc0..000000000
--- a/apps/reader/apps/mobile/hooks/useAudio.ts
+++ /dev/null
@@ -1,416 +0,0 @@
-import { useState, useEffect, useCallback } from 'react';
-import { Audio } from 'expo-av';
-import { AudioService, AudioGenerationProgress } from '~/services/audioService';
-import { useTexts } from './useTexts';
-import { useStore } from '~/store/store';
-import { AudioChunk } from '~/types/database';
-
-export interface AudioState {
- isPlaying: boolean;
- isLoading: boolean;
- currentPosition: number;
- duration: number;
- currentChunk?: AudioChunk;
- sound?: Audio.Sound;
- playbackRate: number;
-}
-
-export const useAudio = () => {
- const { settings, updateSettings } = useStore();
- const { updateText } = useTexts();
-
- const [audioState, setAudioState] = useState({
- isPlaying: false,
- isLoading: false,
- currentPosition: 0,
- duration: 0,
- playbackRate: settings.playbackRate || 1.0,
- });
-
- const [generationProgress, setGenerationProgress] = useState(
- null
- );
- const [downloadProgress, setDownloadProgress] = useState<{
- completed: number;
- total: number;
- currentChunk: string;
- } | null>(null);
-
- const { setCurrentText, setIsPlaying, setCurrentPosition } = useStore();
- const audioService = AudioService.getInstance();
-
- // Initialize audio session
- useEffect(() => {
- const initializeAudio = async () => {
- try {
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: false,
- playsInSilentModeIOS: true,
- shouldDuckAndroid: true,
- staysActiveInBackground: true,
- playThroughEarpieceAndroid: false,
- });
- } catch (error) {
- console.error('Error initializing audio:', error);
- }
- };
-
- initializeAudio();
- }, []);
-
- // Clean up audio when component unmounts
- useEffect(() => {
- return () => {
- if (audioState.sound) {
- audioState.sound.unloadAsync();
- }
- };
- }, [audioState.sound]);
-
- // Generate audio for a text
- const generateAudio = useCallback(
- async (
- textId: string,
- content: string,
- voice: string = 'de-DE',
- speed: number = 1.0,
- currentText?: any
- ) => {
- try {
- setGenerationProgress({
- chunksCompleted: 0,
- totalChunks: 1,
- currentChunk: 'Starting...',
- isComplete: false,
- });
-
- // Import migration helper
- const { generateVersionId } = await import('~/utils/audioMigration');
- const newVersionId = generateVersionId();
-
- const result = await audioService.generateAudioForText(
- textId,
- content,
- voice,
- speed,
- 1000,
- setGenerationProgress,
- newVersionId
- );
-
- if (!result.success) {
- throw new Error(result.error);
- }
-
- // Get current text to append to audioVersions
- if (!currentText) {
- throw new Error('Text must be provided to generate audio');
- }
-
- // Import migration helper for existing code
- const { migrateAudioData } = await import('~/utils/audioMigration');
-
- // Migrate old data if needed
- const migratedData = migrateAudioData(currentText.data);
- const newAudioVersion = {
- id: newVersionId,
- chunks: result.chunks || [],
- settings: { voice, speed },
- totalSize: result.chunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
- hasLocalCache: false,
- createdAt: new Date().toISOString(),
- };
-
- // Append new version to audioVersions
- const updatedAudioVersions = [...(migratedData.audioVersions || []), newAudioVersion];
-
- // Update text with new audio version
- await updateText(textId, {
- data: {
- ...migratedData,
- audioVersions: updatedAudioVersions,
- currentAudioVersion: newVersionId,
- // Keep legacy audio field for backward compatibility
- audio: {
- hasLocalCache: false,
- chunks: result.chunks || [],
- totalSize: newAudioVersion.totalSize,
- lastGenerated: newAudioVersion.createdAt,
- settings: { voice, speed },
- },
- },
- });
-
- return result;
- } catch (error) {
- console.error('Error generating audio:', error);
- throw error;
- } finally {
- setGenerationProgress(null);
- }
- },
- [audioService, updateText]
- );
-
- // Download audio chunks to local storage
- const downloadAudio = useCallback(
- async (textId: string, chunks: AudioChunk[]) => {
- try {
- setDownloadProgress({
- completed: 0,
- total: chunks.length,
- currentChunk: 'Starting download...',
- });
-
- const result = await audioService.downloadAudioChunks(textId, chunks, setDownloadProgress);
-
- if (!result.success) {
- throw new Error(result.error);
- }
-
- // Update text to mark as locally cached
- await updateText(textId, {
- data: {
- audio: {
- hasLocalCache: true,
- chunks: result.localChunks || chunks,
- totalSize: result.localChunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
- lastGenerated: new Date().toISOString(),
- },
- },
- });
-
- return result;
- } catch (error) {
- console.error('Error downloading audio:', error);
- throw error;
- } finally {
- setDownloadProgress(null);
- }
- },
- [audioService, updateText]
- );
-
- // Play audio from local cache
- const playAudio = useCallback(
- async (textId: string, chunks: AudioChunk[], startPosition: number = 0) => {
- try {
- setAudioState((prev) => ({ ...prev, isLoading: true }));
-
- // Stop current audio if playing
- if (audioState.sound) {
- audioState.sound.unloadAsync();
- }
-
- // Calculate total duration from all chunks
- const totalDuration = chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000; // Convert to milliseconds
-
- const result = await audioService.playAudioFromSupabase(textId, chunks, startPosition);
-
- if (!result.sound) {
- throw new Error(result.error);
- }
-
- const currentChunk = result.chunk;
- const allChunks = result.chunks || chunks;
-
- // Set up playback status update
- result.sound.setOnPlaybackStatusUpdate((status) => {
- if (status.isLoaded) {
- // Calculate the actual position across all chunks
- const chunkPosition = status.positionMillis || 0;
- const overallPosition = currentChunk
- ? currentChunk.start + chunkPosition
- : chunkPosition;
-
- setAudioState((prev) => ({
- ...prev,
- isPlaying: status.isPlaying,
- currentPosition: overallPosition,
- duration: totalDuration, // Keep using total duration
- }));
-
- // Update global store
- setIsPlaying(status.isPlaying);
- setCurrentPosition(overallPosition);
- }
- });
-
- setAudioState((prev) => ({
- ...prev,
- sound: result.sound,
- isLoading: false,
- isPlaying: true,
- duration: totalDuration, // Set total duration of all chunks
- currentChunk: currentChunk,
- }));
-
- setCurrentText(textId);
-
- // Start playing
- await result.sound.playAsync();
-
- // Apply saved playback rate
- if (audioState.playbackRate !== 1.0) {
- await result.sound.setRateAsync(audioState.playbackRate, true);
- }
- } catch (error) {
- console.error('Error playing audio:', error);
- setAudioState((prev) => ({ ...prev, isLoading: false }));
- throw error;
- }
- },
- [
- audioState.sound,
- audioState.playbackRate,
- audioService,
- setCurrentText,
- setIsPlaying,
- setCurrentPosition,
- ]
- );
-
- // Pause audio
- const pauseAudio = useCallback(async () => {
- if (audioState.sound) {
- await audioState.sound.pauseAsync();
- setAudioState((prev) => ({ ...prev, isPlaying: false }));
- setIsPlaying(false);
- }
- }, [audioState.sound, setIsPlaying]);
-
- // Resume audio
- const resumeAudio = useCallback(async () => {
- if (audioState.sound) {
- await audioState.sound.playAsync();
- setAudioState((prev) => ({ ...prev, isPlaying: true }));
- setIsPlaying(true);
- }
- }, [audioState.sound, setIsPlaying]);
-
- // Stop audio
- const stopAudio = useCallback(async () => {
- if (audioState.sound) {
- await audioState.sound.pauseAsync();
- await audioState.sound.unloadAsync();
- setAudioState((prev) => ({
- ...prev,
- sound: undefined,
- isPlaying: false,
- currentPosition: 0,
- duration: 0,
- }));
- setCurrentText(null);
- setIsPlaying(false);
- setCurrentPosition(0);
- }
- }, [audioState.sound, setCurrentText, setIsPlaying, setCurrentPosition]);
-
- // Seek to position
- const seekTo = useCallback(
- async (position: number) => {
- if (audioState.sound) {
- await audioState.sound.setPositionAsync(position);
- }
- },
- [audioState.sound]
- );
-
- // Seek forward by seconds
- const seekForward = useCallback(
- async (seconds: number = 15) => {
- if (audioState.sound && audioState.duration > 0) {
- const newPosition = Math.min(
- audioState.currentPosition + seconds * 1000,
- audioState.duration
- );
- await audioState.sound.setPositionAsync(newPosition);
- }
- },
- [audioState.sound, audioState.currentPosition, audioState.duration]
- );
-
- // Seek backward by seconds
- const seekBackward = useCallback(
- async (seconds: number = 15) => {
- if (audioState.sound) {
- const newPosition = Math.max(audioState.currentPosition - seconds * 1000, 0);
- await audioState.sound.setPositionAsync(newPosition);
- }
- },
- [audioState.sound, audioState.currentPosition]
- );
-
- // Set playback speed
- const setPlaybackSpeed = useCallback(
- async (rate: number) => {
- if (audioState.sound) {
- try {
- await audioState.sound.setRateAsync(rate, true);
- setAudioState((prev) => ({ ...prev, playbackRate: rate }));
- // Persist to store
- updateSettings({ playbackRate: rate });
- } catch (error) {
- console.error('Error setting playback rate:', error);
- }
- } else {
- // If no sound is playing, just update the state for next playback
- setAudioState((prev) => ({ ...prev, playbackRate: rate }));
- // Persist to store
- updateSettings({ playbackRate: rate });
- }
- },
- [audioState.sound, updateSettings]
- );
-
- // Clear audio cache
- const clearCache = useCallback(
- async (textId: string, chunks: AudioChunk[]) => {
- await audioService.clearAudioCache(textId, chunks);
-
- // Update text to mark as not cached
- await updateText(textId, {
- data: {
- audio: {
- hasLocalCache: false,
- chunks,
- totalSize: 0,
- },
- },
- });
- },
- [audioService, updateText]
- );
-
- // Get cache size
- const getCacheSize = useCallback(async () => {
- return await audioService.getCacheSize();
- }, [audioService]);
-
- // Check if audio is cached
- const isAudioCached = useCallback(
- async (textId: string, chunks: AudioChunk[]) => {
- return await audioService.isAudioCached(textId, chunks);
- },
- [audioService]
- );
-
- return {
- audioState,
- generationProgress,
- downloadProgress,
- generateAudio,
- downloadAudio,
- playAudio,
- pauseAudio,
- resumeAudio,
- stopAudio,
- seekTo,
- seekForward,
- seekBackward,
- setPlaybackSpeed,
- clearCache,
- getCacheSize,
- isAudioCached,
- };
-};
diff --git a/apps/reader/apps/mobile/hooks/useAuth.ts b/apps/reader/apps/mobile/hooks/useAuth.ts
deleted file mode 100644
index 2e8e529dc..000000000
--- a/apps/reader/apps/mobile/hooks/useAuth.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { useEffect, useState } from 'react';
-import { supabase } from '~/utils/supabase';
-import { useStore } from '~/store/store';
-import { Session } from '@supabase/supabase-js';
-
-export const useAuth = () => {
- const [session, setSession] = useState(null);
- const [loading, setLoading] = useState(true);
- const { setUser } = useStore();
-
- useEffect(() => {
- // Get initial session
- supabase.auth.getSession().then(({ data: { session } }) => {
- setSession(session);
- if (session?.user) {
- setUser({
- id: session.user.id,
- email: session.user.email!,
- });
- }
- setLoading(false);
- });
-
- // Listen for auth changes
- const {
- data: { subscription },
- } = supabase.auth.onAuthStateChange((_event, session) => {
- setSession(session);
- if (session?.user) {
- setUser({
- id: session.user.id,
- email: session.user.email!,
- });
- } else {
- setUser(null);
- }
- });
-
- return () => subscription.unsubscribe();
- }, [setUser]);
-
- const signUp = async (email: string, password: string) => {
- try {
- const { data, error } = await supabase.auth.signUp({
- email,
- password,
- });
-
- if (error) throw error;
- return { data, error: null };
- } catch (error) {
- return {
- data: null,
- error: error instanceof Error ? error.message : 'Fehler bei der Registrierung',
- };
- }
- };
-
- const signIn = async (email: string, password: string) => {
- try {
- const { data, error } = await supabase.auth.signInWithPassword({
- email,
- password,
- });
-
- if (error) throw error;
- return { data, error: null };
- } catch (error) {
- return {
- data: null,
- error: error instanceof Error ? error.message : 'Fehler beim Anmelden',
- };
- }
- };
-
- const signOut = async () => {
- try {
- const { error } = await supabase.auth.signOut();
- if (error) throw error;
- return { error: null };
- } catch (error) {
- return {
- error: error instanceof Error ? error.message : 'Fehler beim Abmelden',
- };
- }
- };
-
- const resetPassword = async (email: string) => {
- try {
- const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
- redirectTo: 'reader://reset-password',
- });
-
- if (error) throw error;
- return { data, error: null };
- } catch (error) {
- return {
- data: null,
- error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
- };
- }
- };
-
- return {
- session,
- user: session?.user ?? null,
- loading,
- signUp,
- signIn,
- signOut,
- resetPassword,
- };
-};
diff --git a/apps/reader/apps/mobile/hooks/useTexts.ts b/apps/reader/apps/mobile/hooks/useTexts.ts
deleted file mode 100644
index d3e63a7b3..000000000
--- a/apps/reader/apps/mobile/hooks/useTexts.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { useState, useEffect } from 'react';
-import { supabase } from '~/utils/supabase';
-import { Text, TextData } from '~/types/database';
-
-export const useTexts = () => {
- const [texts, setTexts] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- fetchTexts();
-
- // Realtime Subscription
- const subscription = supabase
- .channel('texts_changes')
- .on(
- 'postgres_changes',
- {
- event: '*',
- schema: 'public',
- table: 'texts',
- },
- (payload) => {
- if (payload.eventType === 'INSERT') {
- // Check if text already exists to avoid duplicates
- setTexts((prev) => {
- const exists = prev.some((text) => text.id === payload.new.id);
- if (exists) return prev;
- return [payload.new as Text, ...prev];
- });
- } else if (payload.eventType === 'UPDATE') {
- setTexts((prev) =>
- prev.map((text) => (text.id === payload.new.id ? (payload.new as Text) : text))
- );
- } else if (payload.eventType === 'DELETE') {
- setTexts((prev) => prev.filter((text) => text.id !== payload.old.id));
- }
- }
- )
- .subscribe();
-
- return () => {
- subscription.unsubscribe();
- };
- }, []);
-
- const fetchTexts = async () => {
- try {
- setLoading(true);
- const { data, error } = await supabase
- .from('texts')
- .select('*')
- .order('updated_at', { ascending: false });
-
- if (error) throw error;
- setTexts(data || []);
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
- } finally {
- setLoading(false);
- }
- };
-
- const createText = async (title: string, content: string, initialData?: Partial) => {
- try {
- // Get current user
- const {
- data: { user },
- } = await supabase.auth.getUser();
-
- if (!user) {
- throw new Error('Benutzer nicht eingeloggt');
- }
-
- const { data, error } = await supabase
- .from('texts')
- .insert({
- title,
- content,
- user_id: user.id, // Explicitly set user_id
- data: {
- tts: { speed: 1.0, voice: 'de-DE-Neural2-A' },
- tags: [],
- stats: { playCount: 0, totalTime: 0, completed: false },
- ...initialData,
- },
- })
- .select()
- .single();
-
- if (error) throw error;
-
- // Refresh the texts list to ensure we have the latest data
- await fetchTexts();
-
- return { data, error: null };
- } catch (err) {
- return {
- data: null,
- error: err instanceof Error ? err.message : 'Fehler beim Erstellen',
- };
- }
- };
-
- const updateText = async (textId: string, updates: Partial) => {
- try {
- const { data, error } = await supabase
- .from('texts')
- .update(updates)
- .eq('id', textId)
- .select()
- .single();
-
- if (error) throw error;
- return { data, error: null };
- } catch (err) {
- return {
- data: null,
- error: err instanceof Error ? err.message : 'Fehler beim Aktualisieren',
- };
- }
- };
-
- const deleteText = async (textId: string) => {
- try {
- const { error } = await supabase.from('texts').delete().eq('id', textId);
-
- if (error) throw error;
- return { error: null };
- } catch (err) {
- return {
- error: err instanceof Error ? err.message : 'Fehler beim Löschen',
- };
- }
- };
-
- const updatePosition = async (textId: string, position: number) => {
- const text = texts.find((t) => t.id === textId);
- if (!text) return { error: 'Text nicht gefunden' };
-
- return updateText(textId, {
- data: {
- ...text.data,
- tts: {
- ...text.data.tts,
- lastPosition: position,
- lastPlayed: new Date().toISOString(),
- },
- },
- });
- };
-
- const getTextsByTag = (tag: string) => {
- return texts.filter((text) => text.data.tags?.includes(tag));
- };
-
- const getAllTags = () => {
- const tagSet = new Set();
- texts.forEach((text) => {
- text.data.tags?.forEach((tag) => tagSet.add(tag));
- });
- return Array.from(tagSet).sort();
- };
-
- return {
- texts,
- loading,
- error,
- createText,
- updateText,
- deleteText,
- updatePosition,
- getTextsByTag,
- getAllTags,
- refetch: fetchTexts,
- };
-};
diff --git a/apps/reader/apps/mobile/hooks/useTheme.ts b/apps/reader/apps/mobile/hooks/useTheme.ts
deleted file mode 100644
index ea0b10a04..000000000
--- a/apps/reader/apps/mobile/hooks/useTheme.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { useStore } from '~/store/store';
-
-export interface ThemeColors {
- // Background colors
- background: string;
- surface: string;
- surfaceSecondary: string;
-
- // Text colors
- text: string;
- textSecondary: string;
- textTertiary: string;
-
- // Border colors
- border: string;
- borderSecondary: string;
-
- // Primary colors
- primary: string;
- primaryLight: string;
- primaryDark: string;
-
- // Status colors
- success: string;
- successLight: string;
- warning: string;
- warningLight: string;
- error: string;
- errorLight: string;
-
- // Tab bar colors
- tabBarBackground: string;
- tabBarBorder: string;
- tabBarActive: string;
- tabBarInactive: string;
-}
-
-const lightTheme: ThemeColors = {
- // Background colors
- background: 'bg-gray-50',
- surface: 'bg-white',
- surfaceSecondary: 'bg-gray-100',
-
- // Text colors
- text: 'text-gray-900',
- textSecondary: 'text-gray-600',
- textTertiary: 'text-gray-500',
-
- // Border colors
- border: 'border-gray-200',
- borderSecondary: 'border-gray-300',
-
- // Primary colors
- primary: 'bg-blue-600',
- primaryLight: 'bg-blue-50',
- primaryDark: 'bg-blue-700',
-
- // Status colors
- success: 'bg-green-600',
- successLight: 'bg-green-100',
- warning: 'bg-orange-600',
- warningLight: 'bg-orange-100',
- error: 'bg-red-600',
- errorLight: 'bg-red-50',
-
- // Tab bar colors
- tabBarBackground: '#ffffff',
- tabBarBorder: '#e5e7eb',
- tabBarActive: '#3B82F6',
- tabBarInactive: '#6b7280',
-};
-
-const darkTheme: ThemeColors = {
- // Background colors
- background: 'bg-gray-900',
- surface: 'bg-gray-800',
- surfaceSecondary: 'bg-gray-700',
-
- // Text colors
- text: 'text-white',
- textSecondary: 'text-gray-300',
- textTertiary: 'text-gray-400',
-
- // Border colors
- border: 'border-gray-600',
- borderSecondary: 'border-gray-500',
-
- // Primary colors
- primary: 'bg-blue-600',
- primaryLight: 'bg-blue-900',
- primaryDark: 'bg-blue-700',
-
- // Status colors
- success: 'bg-green-600',
- successLight: 'bg-green-900',
- warning: 'bg-orange-600',
- warningLight: 'bg-orange-900',
- error: 'bg-red-600',
- errorLight: 'bg-red-900',
-
- // Tab bar colors
- tabBarBackground: '#1f2937',
- tabBarBorder: '#374151',
- tabBarActive: '#3B82F6',
- tabBarInactive: '#9ca3af',
-};
-
-export const useTheme = () => {
- const { settings } = useStore();
- const isDark = settings.theme === 'dark';
-
- const colors = isDark ? darkTheme : lightTheme;
-
- return {
- isDark,
- colors,
- theme: settings.theme,
- };
-};
-
-// Text color utilities
-export const useTextColors = () => {
- const { colors } = useTheme();
-
- return {
- primary: colors.text,
- secondary: colors.textSecondary,
- tertiary: colors.textTertiary,
- primaryText: colors.text.replace('text-', 'text-'),
- secondaryText: colors.textSecondary.replace('text-', 'text-'),
- tertiaryText: colors.textTertiary.replace('text-', 'text-'),
- };
-};
-
-// Background color utilities
-export const useBackgroundColors = () => {
- const { colors } = useTheme();
-
- return {
- main: colors.background,
- surface: colors.surface,
- surfaceSecondary: colors.surfaceSecondary,
- };
-};
-
-// Border color utilities
-export const useBorderColors = () => {
- const { colors } = useTheme();
-
- return {
- main: colors.border,
- secondary: colors.borderSecondary,
- };
-};
-
-// Status color utilities
-export const useStatusColors = () => {
- const { colors } = useTheme();
-
- return {
- success: colors.success,
- successLight: colors.successLight,
- warning: colors.warning,
- warningLight: colors.warningLight,
- error: colors.error,
- errorLight: colors.errorLight,
- };
-};
-
-// Primary color utilities
-export const usePrimaryColors = () => {
- const { colors } = useTheme();
-
- return {
- main: colors.primary,
- light: colors.primaryLight,
- dark: colors.primaryDark,
- };
-};
diff --git a/apps/reader/apps/mobile/metro.config.js b/apps/reader/apps/mobile/metro.config.js
deleted file mode 100644
index f4bb1a39d..000000000
--- a/apps/reader/apps/mobile/metro.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-// Learn more https://docs.expo.io/guides/customizing-metro
-const { getDefaultConfig } = require('expo/metro-config');
-
-const { withNativeWind } = require('nativewind/metro');
-
-/** @type {import('expo/metro-config').MetroConfig} */
-
-const config = getDefaultConfig(__dirname);
-
-module.exports = withNativeWind(config, { input: './global.css' });
diff --git a/apps/reader/apps/mobile/nativewind-env.d.ts b/apps/reader/apps/mobile/nativewind-env.d.ts
deleted file mode 100644
index 958346287..000000000
--- a/apps/reader/apps/mobile/nativewind-env.d.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-///
-
-// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
diff --git a/apps/reader/apps/mobile/package.json b/apps/reader/apps/mobile/package.json
deleted file mode 100644
index cd519cbd9..000000000
--- a/apps/reader/apps/mobile/package.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
- "name": "@reader/mobile",
- "version": "1.0.0",
- "main": "expo-router/entry",
- "scripts": {
- "dev": "expo start",
- "start": "expo start --dev-client",
- "ios": "expo run:ios",
- "android": "expo run:android",
- "build:dev": "eas build --profile development",
- "build:preview": "eas build --profile preview",
- "build:prod": "eas build --profile production",
- "prebuild": "expo prebuild",
- "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
- "format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
- "web": "expo start --web"
- },
- "dependencies": {
- "@expo/vector-icons": "^14.0.0",
- "@react-native-async-storage/async-storage": "^2.1.2",
- "@react-navigation/native": "^7.0.3",
- "@supabase/supabase-js": "^2.38.4",
- "expo": "^53.0.19",
- "expo-av": "^15.1.7",
- "expo-clipboard": "^7.1.5",
- "expo-constants": "~17.1.4",
- "expo-dev-client": "~5.2.4",
- "expo-dev-launcher": "^5.0.17",
- "expo-file-system": "^18.1.11",
- "expo-linking": "~7.1.4",
- "expo-router": "~5.1.3",
- "expo-status-bar": "~2.2.3",
- "expo-system-ui": "~5.0.6",
- "expo-web-browser": "~14.2.0",
- "nativewind": "latest",
- "react": "19.0.0",
- "react-dom": "19.0.0",
- "react-native": "0.79.5",
- "react-native-gesture-handler": "~2.24.0",
- "react-native-reanimated": "~3.17.4",
- "react-native-safe-area-context": "5.4.0",
- "react-native-screens": "~4.11.1",
- "react-native-web": "^0.20.0",
- "zustand": "^4.5.7"
- },
- "devDependencies": {
- "@babel/core": "^7.20.0",
- "@types/react": "~19.0.10",
- "ajv": "^8.12.0",
- "eslint": "^9.25.1",
- "eslint-config-expo": "^9.2.0",
- "eslint-config-prettier": "^10.1.2",
- "prettier": "^3.2.5",
- "prettier-plugin-tailwindcss": "^0.5.11",
- "tailwindcss": "^3.4.0",
- "typescript": "~5.8.3"
- },
- "private": true
-}
diff --git a/apps/reader/apps/mobile/prettier.config.js b/apps/reader/apps/mobile/prettier.config.js
deleted file mode 100644
index a8f56d840..000000000
--- a/apps/reader/apps/mobile/prettier.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-module.exports = {
- printWidth: 100,
- tabWidth: 2,
- singleQuote: true,
- bracketSameLine: true,
- trailingComma: 'es5',
-
- plugins: [require.resolve('prettier-plugin-tailwindcss')],
- tailwindAttributes: ['className'],
-};
diff --git a/apps/reader/apps/mobile/services/audioService.ts b/apps/reader/apps/mobile/services/audioService.ts
deleted file mode 100644
index 54b021b8b..000000000
--- a/apps/reader/apps/mobile/services/audioService.ts
+++ /dev/null
@@ -1,339 +0,0 @@
-import { supabase } from '~/utils/supabase';
-import * as FileSystem from 'expo-file-system';
-import { Audio } from 'expo-av';
-import { AudioChunk } from '~/types/database';
-import { getVoiceById } from '~/constants/voices';
-
-const AUDIO_DIR = `${FileSystem.documentDirectory}audio/`;
-
-export interface AudioGenerationProgress {
- chunksCompleted: number;
- totalChunks: number;
- currentChunk: string;
- isComplete: boolean;
-}
-
-export class AudioService {
- private static instance: AudioService;
- private supabase = supabase;
-
- public static getInstance(): AudioService {
- if (!AudioService.instance) {
- AudioService.instance = new AudioService();
- }
- return AudioService.instance;
- }
-
- private constructor() {
- this.initializeAudioDirectory();
- }
-
- private async initializeAudioDirectory(): Promise {
- try {
- await FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true });
- } catch {
- // Directory might already exist
- }
- }
-
- // Generate audio for a text using Supabase Edge Function
- async generateAudioForText(
- textId: string,
- content: string,
- voice: string = 'de-DE',
- speed: number = 1.0,
- chunkSize: number = 1000,
- onProgress?: (progress: AudioGenerationProgress) => void,
- versionId?: string
- ): Promise<{ success: boolean; error?: string; chunks?: AudioChunk[] }> {
- try {
- // Estimate number of chunks for progress tracking
- const estimatedChunks = Math.ceil(content.length / chunkSize);
-
- onProgress?.({
- chunksCompleted: 0,
- totalChunks: estimatedChunks,
- currentChunk: 'Starting generation...',
- isComplete: false,
- });
-
- // Determine which provider to use based on the voice
- let provider = 'google';
-
- try {
- const voiceInfo = getVoiceById(voice);
- if (voiceInfo) {
- provider = voiceInfo.provider;
- } else {
- console.warn(`Voice not found: ${voice}, defaulting to Google provider`);
- }
- } catch (error) {
- console.error('Error getting voice info:', error);
- // Continue with default Google provider
- }
-
- const { data, error } = await supabase.functions.invoke('generate-audio', {
- body: {
- textId,
- content,
- voice,
- provider,
- speed,
- chunkSize,
- versionId,
- },
- });
-
- if (error) {
- throw error;
- }
-
- if (!data.success) {
- throw new Error(data.error || 'Failed to generate audio');
- }
-
- onProgress?.({
- chunksCompleted: data.chunksGenerated,
- totalChunks: data.chunksGenerated,
- currentChunk: 'Audio generation complete!',
- isComplete: true,
- });
-
- return {
- success: true,
- chunks: data.chunks,
- };
- } catch (error) {
- console.error('Error generating audio:', error);
- return {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- };
- }
- }
-
- // Download audio chunks to local storage
- async downloadAudioChunks(
- textId: string,
- chunks: AudioChunk[],
- onProgress?: (progress: { completed: number; total: number; currentChunk: string }) => void
- ): Promise<{ success: boolean; error?: string; localChunks?: AudioChunk[] }> {
- try {
- const localChunks: AudioChunk[] = [];
-
- for (let i = 0; i < chunks.length; i++) {
- const chunk = chunks[i];
-
- onProgress?.({
- completed: i,
- total: chunks.length,
- currentChunk: chunk.id,
- });
-
- // Get signed URL for the chunk
- const { data: urlData, error: urlError } = await supabase.functions.invoke(
- 'get-audio-url',
- {
- body: {
- textId,
- chunkId: chunk.id,
- },
- }
- );
-
- if (urlError || !urlData.success) {
- throw new Error(`Failed to get URL for chunk ${chunk.id}`);
- }
-
- // Download the audio file
- const localFilePath = `${AUDIO_DIR}${textId}_${chunk.id}.mp3`;
- const downloadResult = await FileSystem.downloadAsync(urlData.url, localFilePath);
-
- if (downloadResult.status !== 200) {
- throw new Error(`Failed to download chunk ${chunk.id}`);
- }
-
- // Get file info
- const fileInfo = await FileSystem.getInfoAsync(localFilePath);
-
- localChunks.push({
- ...chunk,
- filename: `${textId}_${chunk.id}.mp3`,
- size: fileInfo.exists && 'size' in fileInfo ? fileInfo.size : chunk.size,
- });
- }
-
- onProgress?.({
- completed: chunks.length,
- total: chunks.length,
- currentChunk: 'Download complete!',
- });
-
- return {
- success: true,
- localChunks,
- };
- } catch (error) {
- console.error('Error downloading audio chunks:', error);
- return {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- };
- }
- }
-
- // Play audio directly from Supabase Storage
- async playAudioFromSupabase(
- textId: string,
- chunks: AudioChunk[],
- startPosition: number = 0
- ): Promise<{ sound?: Audio.Sound; chunk?: AudioChunk; chunks?: AudioChunk[]; error?: string }> {
- try {
- // Calculate chunk positions if not already set
- let currentPosition = 0;
- const chunksWithPositions = chunks.map((chunk) => {
- const chunkStart = currentPosition;
- const chunkEnd = currentPosition + chunk.duration * 1000; // Convert to milliseconds
- currentPosition = chunkEnd;
- return {
- ...chunk,
- start: chunk.start ?? chunkStart,
- end: chunk.end ?? chunkEnd,
- };
- });
-
- // Find the chunk that contains the start position
- const chunk =
- chunksWithPositions.find((c) => startPosition >= c.start && startPosition < c.end) ||
- chunksWithPositions[0]; // Default to first chunk if position not found
-
- if (!chunk) {
- throw new Error('No chunk found for the given position');
- }
-
- // Get signed URL for the audio chunk
- const { data: urlData, error: urlError } = await this.supabase.functions.invoke(
- 'get-audio-url',
- {
- body: {
- textId,
- chunkId: chunk.id,
- },
- }
- );
-
- if (urlError || !urlData.success) {
- throw new Error(`Failed to get audio URL: ${urlError?.message || 'Unknown error'}`);
- }
-
- // Create and load the audio from signed URL
- const { sound } = await Audio.Sound.createAsync({ uri: urlData.url });
-
- // Calculate position within the chunk
- const positionWithinChunk = Math.max(0, startPosition - chunk.start);
- await sound.setPositionAsync(positionWithinChunk);
-
- return { sound, chunk, chunks: chunksWithPositions };
- } catch (error) {
- console.error('Error playing audio from Supabase:', error);
- return {
- error: error instanceof Error ? error.message : 'Unknown error',
- };
- }
- }
-
- // Play audio from local cache (kept for backward compatibility)
- async playAudioFromCache(
- textId: string,
- chunks: AudioChunk[],
- startPosition: number = 0
- ): Promise<{ sound?: Audio.Sound; error?: string }> {
- try {
- // Find the chunk that contains the start position
- const chunk = chunks.find((c) => startPosition >= c.start && startPosition < c.end);
-
- if (!chunk) {
- throw new Error('No chunk found for the given position');
- }
-
- const filePath = `${AUDIO_DIR}${chunk.filename}`;
- const fileInfo = await FileSystem.getInfoAsync(filePath);
-
- if (!fileInfo.exists) {
- throw new Error('Audio file not found locally');
- }
-
- // Create and load the audio
- const { sound } = await Audio.Sound.createAsync({ uri: filePath });
-
- // Calculate position within the chunk
- const chunkProgress = (startPosition - chunk.start) / (chunk.end - chunk.start);
- const positionMillis = chunkProgress * chunk.duration * 1000;
-
- await sound.setPositionAsync(positionMillis);
-
- return { sound };
- } catch (error) {
- console.error('Error playing audio from cache:', error);
- return {
- error: error instanceof Error ? error.message : 'Unknown error',
- };
- }
- }
-
- // Clear local audio cache for a text
- async clearAudioCache(textId: string, chunks: AudioChunk[]): Promise {
- try {
- for (const chunk of chunks) {
- const filePath = `${AUDIO_DIR}${chunk.filename}`;
- try {
- await FileSystem.deleteAsync(filePath);
- } catch (deleteError) {
- console.log(`Could not delete ${chunk.filename}:`, deleteError);
- }
- }
- } catch (error) {
- console.error('Error clearing audio cache:', error);
- }
- }
-
- // Get total cache size
- async getCacheSize(): Promise {
- try {
- const files = await FileSystem.readDirectoryAsync(AUDIO_DIR);
- let totalSize = 0;
-
- for (const file of files) {
- const fileInfo = await FileSystem.getInfoAsync(`${AUDIO_DIR}${file}`);
- totalSize += fileInfo.exists && 'size' in fileInfo ? fileInfo.size : 0;
- }
-
- return totalSize;
- } catch (error) {
- console.error('Error calculating cache size:', error);
- return 0;
- }
- }
-
- // Check if audio is cached locally
- async isAudioCached(textId: string, chunks: AudioChunk[]): Promise {
- try {
- for (const chunk of chunks) {
- const filePath = `${AUDIO_DIR}${chunk.filename}`;
- const fileInfo = await FileSystem.getInfoAsync(filePath);
-
- if (!fileInfo.exists) {
- return false;
- }
- }
- return true;
- } catch {
- return false;
- }
- }
-
- // Get file path for a chunk
- getChunkFilePath(textId: string, chunkId: string): string {
- return `${AUDIO_DIR}${textId}_${chunkId}.mp3`;
- }
-}
diff --git a/apps/reader/apps/mobile/services/urlExtractorService.ts b/apps/reader/apps/mobile/services/urlExtractorService.ts
deleted file mode 100644
index f5e2a641b..000000000
--- a/apps/reader/apps/mobile/services/urlExtractorService.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { supabase } from '~/utils/supabase';
-
-export interface ExtractedContent {
- title: string;
- content: string;
- excerpt: string;
- source: string;
- domain: string;
- author: string;
- publishDate: string;
- wordCount: number;
- readingTime: number;
- tags: string[];
-}
-
-export interface ExtractUrlError {
- message: string;
- code?: 'INVALID_URL' | 'FETCH_FAILED' | 'EXTRACTION_FAILED' | 'NETWORK_ERROR' | 'UNAUTHORIZED';
-}
-
-class UrlExtractorService {
- async extractFromUrl(
- url: string
- ): Promise<{ data: ExtractedContent | null; error: ExtractUrlError | null }> {
- try {
- // Basic URL validation
- const urlPattern = /^https?:\/\/.+/;
- if (!urlPattern.test(url)) {
- return {
- data: null,
- error: {
- message: 'Bitte gib eine gültige URL ein (http:// oder https://)',
- code: 'INVALID_URL',
- },
- };
- }
-
- const { data, error } = await supabase.functions.invoke('extract-url', {
- body: { url },
- });
-
- if (error) {
- console.error('Error extracting URL:', error);
-
- // Handle specific error cases
- if (error.message?.includes('Unauthorized')) {
- return {
- data: null,
- error: {
- message: 'Nicht autorisiert. Bitte melde dich erneut an.',
- code: 'UNAUTHORIZED',
- },
- };
- }
-
- if (error.message?.includes('Failed to fetch URL')) {
- return {
- data: null,
- error: {
- message: 'Die Webseite konnte nicht geladen werden. Überprüfe die URL.',
- code: 'FETCH_FAILED',
- },
- };
- }
-
- if (error.message?.includes('Could not extract')) {
- return {
- data: null,
- error: {
- message:
- 'Der Text konnte nicht extrahiert werden. Die Seite ist möglicherweise nicht kompatibel.',
- code: 'EXTRACTION_FAILED',
- },
- };
- }
-
- return {
- data: null,
- error: { message: error.message || 'Ein Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
- };
- }
-
- if (!data) {
- return {
- data: null,
- error: { message: 'Keine Daten empfangen', code: 'EXTRACTION_FAILED' },
- };
- }
-
- return { data: data as ExtractedContent, error: null };
- } catch (error) {
- console.error('Unexpected error in extractFromUrl:', error);
- return {
- data: null,
- error: { message: 'Ein unerwarteter Fehler ist aufgetreten', code: 'NETWORK_ERROR' },
- };
- }
- }
-
- validateUrl(url: string): boolean {
- try {
- const urlObj = new URL(url);
- return ['http:', 'https:'].includes(urlObj.protocol);
- } catch {
- return false;
- }
- }
-
- formatExtractedContent(extracted: ExtractedContent): string {
- // Format the extracted content with title and metadata
- let formatted = extracted.title + '\n\n';
-
- if (extracted.author) {
- formatted += `Von: ${extracted.author}\n`;
- }
-
- if (extracted.publishDate) {
- formatted += `Veröffentlicht: ${extracted.publishDate}\n`;
- }
-
- if (extracted.domain) {
- formatted += `Quelle: ${extracted.domain}\n`;
- }
-
- formatted += '\n' + extracted.content;
-
- return formatted;
- }
-}
-
-export const urlExtractorService = new UrlExtractorService();
diff --git a/apps/reader/apps/mobile/store/store.ts b/apps/reader/apps/mobile/store/store.ts
deleted file mode 100644
index dd53e0c47..000000000
--- a/apps/reader/apps/mobile/store/store.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { create } from 'zustand';
-import { persist, createJSONStorage } from 'zustand/middleware';
-import AsyncStorage from '@react-native-async-storage/async-storage';
-
-interface User {
- id: string;
- email: string;
-}
-
-interface AppState {
- // User
- user: User | null;
- setUser: (user: User | null) => void;
-
- // Settings
- settings: {
- voice: string;
- speed: number;
- theme: 'light' | 'dark';
- playbackRate: number;
- };
- updateSettings: (settings: Partial) => void;
-
- // Audio Player
- currentTextId: string | null;
- isPlaying: boolean;
- currentPosition: number;
- setCurrentText: (textId: string | null) => void;
- setIsPlaying: (playing: boolean) => void;
- setCurrentPosition: (position: number) => void;
-
- // UI State
- selectedTags: string[];
- toggleTag: (tag: string) => void;
- clearTags: () => void;
-}
-
-export const useStore = create()(
- persist(
- (set) => ({
- // User
- user: null,
- setUser: (user) => set({ user }),
-
- // Settings
- settings: {
- voice: 'de-DE-Neural2-A',
- speed: 1.0,
- theme: 'light',
- playbackRate: 1.0,
- },
- updateSettings: (newSettings) =>
- set((state) => ({
- settings: { ...state.settings, ...newSettings },
- })),
-
- // Audio Player
- currentTextId: null,
- isPlaying: false,
- currentPosition: 0,
- setCurrentText: (textId) => set({ currentTextId: textId, currentPosition: 0 }),
- setIsPlaying: (playing) => set({ isPlaying: playing }),
- setCurrentPosition: (position) => set({ currentPosition: position }),
-
- // UI State
- selectedTags: [],
- toggleTag: (tag) =>
- set((state) => ({
- selectedTags: state.selectedTags.includes(tag)
- ? state.selectedTags.filter((t) => t !== tag)
- : [...state.selectedTags, tag],
- })),
- clearTags: () => set({ selectedTags: [] }),
- }),
- {
- name: 'reader-storage',
- storage: createJSONStorage(() => AsyncStorage),
- partialize: (state) => ({
- settings: state.settings,
- selectedTags: state.selectedTags,
- }),
- }
- )
-);
diff --git a/apps/reader/apps/mobile/supabase/.temp/cli-latest b/apps/reader/apps/mobile/supabase/.temp/cli-latest
deleted file mode 100644
index 19a5f69d2..000000000
--- a/apps/reader/apps/mobile/supabase/.temp/cli-latest
+++ /dev/null
@@ -1 +0,0 @@
-v2.31.4
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/gotrue-version b/apps/reader/apps/mobile/supabase/.temp/gotrue-version
deleted file mode 100644
index debbfeacf..000000000
--- a/apps/reader/apps/mobile/supabase/.temp/gotrue-version
+++ /dev/null
@@ -1 +0,0 @@
-v2.177.0
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/pooler-url b/apps/reader/apps/mobile/supabase/.temp/pooler-url
deleted file mode 100644
index 2b176ad35..000000000
--- a/apps/reader/apps/mobile/supabase/.temp/pooler-url
+++ /dev/null
@@ -1 +0,0 @@
-postgresql://postgres.tiecnhktvovcqsrnunko:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/postgres-version b/apps/reader/apps/mobile/supabase/.temp/postgres-version
deleted file mode 100644
index 8eabea2fd..000000000
--- a/apps/reader/apps/mobile/supabase/.temp/postgres-version
+++ /dev/null
@@ -1 +0,0 @@
-17.4.1.054
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/project-ref b/apps/reader/apps/mobile/supabase/.temp/project-ref
deleted file mode 100644
index 416cb82f4..000000000
--- a/apps/reader/apps/mobile/supabase/.temp/project-ref
+++ /dev/null
@@ -1 +0,0 @@
-tiecnhktvovcqsrnunko
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/rest-version b/apps/reader/apps/mobile/supabase/.temp/rest-version
deleted file mode 100644
index 2392826ee..000000000
--- a/apps/reader/apps/mobile/supabase/.temp/rest-version
+++ /dev/null
@@ -1 +0,0 @@
-v12.2.3
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/.temp/storage-version b/apps/reader/apps/mobile/supabase/.temp/storage-version
deleted file mode 100644
index 04f88252e..000000000
--- a/apps/reader/apps/mobile/supabase/.temp/storage-version
+++ /dev/null
@@ -1 +0,0 @@
-custom-metadata
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/functions/extract-url-scrapingbee/index.ts b/apps/reader/apps/mobile/supabase/functions/extract-url-scrapingbee/index.ts
deleted file mode 100644
index 5a2aaaeff..000000000
--- a/apps/reader/apps/mobile/supabase/functions/extract-url-scrapingbee/index.ts
+++ /dev/null
@@ -1,299 +0,0 @@
-import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
-import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
-import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
-import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
-
-const corsHeaders = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
-};
-
-serve(async (req) => {
- if (req.method === 'OPTIONS') {
- return new Response('ok', { headers: corsHeaders });
- }
-
- try {
- const authHeader = req.headers.get('Authorization');
- if (!authHeader) {
- return new Response(JSON.stringify({ error: 'No authorization header' }), {
- status: 401,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- const supabaseClient = createClient(
- Deno.env.get('SUPABASE_URL') ?? '',
- Deno.env.get('SUPABASE_ANON_KEY') ?? '',
- {
- global: {
- headers: { Authorization: authHeader },
- },
- }
- );
-
- const {
- data: { user },
- error: authError,
- } = await supabaseClient.auth.getUser();
-
- if (authError || !user) {
- return new Response(JSON.stringify({ error: 'Unauthorized' }), {
- status: 401,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- const { url } = await req.json();
-
- if (!url) {
- return new Response(JSON.stringify({ error: 'URL is required' }), {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Validate URL
- let validatedUrl;
- try {
- validatedUrl = new URL(url);
- if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
- throw new Error('Invalid protocol');
- }
- } catch {
- return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Use ScrapingBee API - requires API key in environment
- const scrapingBeeApiKey = Deno.env.get('SCRAPINGBEE_API_KEY');
- if (!scrapingBeeApiKey) {
- console.error('SCRAPINGBEE_API_KEY not configured, falling back to direct fetch');
- // Fallback to direct fetch if API key not configured
- return fallbackExtraction(validatedUrl, corsHeaders);
- }
-
- // ScrapingBee API request
- const scrapingBeeUrl = new URL('https://app.scrapingbee.com/api/v1/');
- scrapingBeeUrl.searchParams.append('api_key', scrapingBeeApiKey);
- scrapingBeeUrl.searchParams.append('url', validatedUrl.toString());
- scrapingBeeUrl.searchParams.append('render_js', 'true'); // Render JavaScript
- scrapingBeeUrl.searchParams.append('wait', '3000'); // Wait 3s for content to load
- scrapingBeeUrl.searchParams.append('block_ads', 'true'); // Block ads
- scrapingBeeUrl.searchParams.append('stealth_mode', 'true'); // Bypass anti-bot measures
-
- // Custom JavaScript to remove cookie banners
- const jsScript = `
- // Remove cookie banners
- const selectors = [
- '[class*="cookie"]', '[id*="cookie"]',
- '[class*="consent"]', '[id*="consent"]',
- '[class*="gdpr"]', '[id*="gdpr"]',
- '.privacy-banner', '#privacy-banner'
- ];
- selectors.forEach(sel => {
- document.querySelectorAll(sel).forEach(el => {
- if (el.textContent.toLowerCase().includes('cookie') ||
- el.textContent.toLowerCase().includes('consent')) {
- el.remove();
- }
- });
- });
- // Click accept buttons if needed
- const acceptButtons = document.querySelectorAll('button, a');
- acceptButtons.forEach(btn => {
- const text = btn.textContent.toLowerCase();
- if ((text.includes('accept') || text.includes('akzeptieren')) &&
- (text.includes('cookie') || text.includes('all'))) {
- btn.click();
- }
- });
- `;
- scrapingBeeUrl.searchParams.append('js_scenario', btoa(jsScript));
-
- const response = await fetch(scrapingBeeUrl.toString());
-
- if (!response.ok) {
- console.error('ScrapingBee error:', response.status, await response.text());
- return fallbackExtraction(validatedUrl, corsHeaders);
- }
-
- const html = await response.text();
-
- // Parse and extract content
- const doc = new DOMParser().parseFromString(html, 'text/html');
-
- if (!doc) {
- return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
- status: 500,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Use Readability to extract article content
- const reader = new Readability(doc);
- const article = reader.parse();
-
- if (!article || article.textContent.length < 200) {
- // Try manual extraction
- return manualExtraction(doc, validatedUrl, corsHeaders);
- }
-
- // Extract metadata
- const metadata = extractMetadata(doc);
- const tags = generateTags(metadata.keywords);
-
- // Clean content
- const cleanedContent = article.textContent
- .replace(/\s+/g, ' ')
- .replace(/\n{3,}/g, '\n\n')
- .trim();
-
- return new Response(
- JSON.stringify({
- title: article.title || 'Untitled',
- content: cleanedContent,
- excerpt: article.excerpt || metadata.description || '',
- source: validatedUrl.toString(),
- domain: validatedUrl.hostname,
- author: article.byline || metadata.author || '',
- publishDate: metadata.publishDate || '',
- wordCount: cleanedContent.split(/\s+/).length,
- readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200),
- tags,
- }),
- {
- status: 200,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- }
- );
- } catch (error) {
- console.error('Extract URL error:', error);
- return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
- status: 500,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-});
-
-// Helper functions
-async function fallbackExtraction(url: URL, corsHeaders: any) {
- // Original extraction logic as fallback
- const response = await fetch(url.toString(), {
- headers: {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
- },
- });
-
- if (!response.ok) {
- return new Response(JSON.stringify({ error: `Failed to fetch URL: ${response.status}` }), {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- const html = await response.text();
- const doc = new DOMParser().parseFromString(html, 'text/html');
-
- return manualExtraction(doc!, url, corsHeaders);
-}
-
-function manualExtraction(doc: any, url: URL, corsHeaders: any) {
- let content = '';
- let title = '';
-
- // Find title
- const titleElement = doc.querySelector('h1') || doc.querySelector('title');
- if (titleElement) {
- title = titleElement.textContent?.trim() || '';
- }
-
- // Find content
- const contentSelectors = [
- 'main',
- 'article',
- '[role="main"]',
- '.content',
- '#content',
- '.post',
- '.entry-content',
- '.article-content',
- ];
-
- for (const selector of contentSelectors) {
- const element = doc.querySelector(selector);
- if (element && element.textContent) {
- content = element.textContent.trim();
- break;
- }
- }
-
- // Get paragraphs
- if (!content || content.length < 200) {
- const paragraphs = doc.querySelectorAll('p');
- const texts: string[] = [];
- paragraphs.forEach((p: any) => {
- const text = p.textContent?.trim();
- if (text && text.length > 50) {
- texts.push(text);
- }
- });
- content = texts.join('\n\n');
- }
-
- if (!content || content.length < 100) {
- return new Response(JSON.stringify({ error: 'Could not extract meaningful content' }), {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- return new Response(
- JSON.stringify({
- title: title || 'Untitled',
- content: content,
- excerpt: content.substring(0, 200),
- source: url.toString(),
- domain: url.hostname,
- author: '',
- publishDate: '',
- wordCount: content.split(/\s+/).length,
- readingTime: Math.ceil(content.split(/\s+/).length / 200),
- tags: [],
- }),
- {
- status: 200,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- }
- );
-}
-
-function extractMetadata(doc: any) {
- const metadata: Record = {};
- const metaTags = doc.querySelectorAll('meta');
-
- metaTags.forEach((meta: any) => {
- const name = meta.getAttribute('name') || meta.getAttribute('property');
- const content = meta.getAttribute('content');
-
- if (name && content) {
- if (name.includes('author')) metadata.author = content;
- if (name.includes('description')) metadata.description = content;
- if (name.includes('keywords')) metadata.keywords = content;
- if (name.includes('publish')) metadata.publishDate = content;
- }
- });
-
- return metadata;
-}
-
-function generateTags(keywords?: string): string[] {
- if (!keywords) return [];
- return keywords
- .split(',')
- .map((k) => k.trim())
- .filter((k) => k.length > 0)
- .slice(0, 5);
-}
diff --git a/apps/reader/apps/mobile/supabase/functions/extract-url/index.ts b/apps/reader/apps/mobile/supabase/functions/extract-url/index.ts
deleted file mode 100644
index 174246331..000000000
--- a/apps/reader/apps/mobile/supabase/functions/extract-url/index.ts
+++ /dev/null
@@ -1,332 +0,0 @@
-import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
-import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
-import { Readability } from 'https://esm.sh/@mozilla/readability@0.5.0';
-import { DOMParser } from 'https://deno.land/x/deno_dom@v0.1.38/deno-dom-wasm.ts';
-
-const corsHeaders = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
-};
-
-serve(async (req) => {
- if (req.method === 'OPTIONS') {
- return new Response('ok', { headers: corsHeaders });
- }
-
- try {
- const authHeader = req.headers.get('Authorization');
- if (!authHeader) {
- return new Response(JSON.stringify({ error: 'No authorization header' }), {
- status: 401,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- const supabaseClient = createClient(
- Deno.env.get('SUPABASE_URL') ?? '',
- Deno.env.get('SUPABASE_ANON_KEY') ?? '',
- {
- global: {
- headers: { Authorization: authHeader },
- },
- }
- );
-
- const {
- data: { user },
- error: authError,
- } = await supabaseClient.auth.getUser();
-
- if (authError || !user) {
- return new Response(JSON.stringify({ error: 'Unauthorized' }), {
- status: 401,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- const { url } = await req.json();
-
- if (!url) {
- return new Response(JSON.stringify({ error: 'URL is required' }), {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Validate URL
- let validatedUrl;
- try {
- validatedUrl = new URL(url);
- if (!['http:', 'https:'].includes(validatedUrl.protocol)) {
- throw new Error('Invalid protocol');
- }
- } catch {
- return new Response(JSON.stringify({ error: 'Invalid URL format' }), {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Try Jina.ai Reader API for better extraction
- try {
- const jinaUrl = `https://r.jina.ai/${validatedUrl.toString()}`;
- const jinaResponse = await fetch(jinaUrl, {
- headers: {
- Accept: 'text/plain',
- 'X-Return-Format': 'text',
- },
- signal: AbortSignal.timeout(15000), // 15 second timeout
- });
-
- if (jinaResponse.ok) {
- const content = await jinaResponse.text();
-
- // Check if we got meaningful content (not just cookie banner)
- if (
- content &&
- content.length > 500 &&
- !content.toLowerCase().includes('cookies zustimmen') &&
- !content.toLowerCase().includes('cookie banner')
- ) {
- // Extract title from content (usually first line)
- const lines = content.split('\n').filter((line) => line.trim());
- const title = lines[0] || 'Untitled';
- const actualContent = lines.slice(1).join('\n\n');
-
- return new Response(
- JSON.stringify({
- title: title.substring(0, 200), // Limit title length
- content: actualContent || content,
- excerpt: actualContent.substring(0, 200),
- source: validatedUrl.toString(),
- domain: validatedUrl.hostname,
- author: '',
- publishDate: '',
- wordCount: content.split(/\s+/).length,
- readingTime: Math.ceil(content.split(/\s+/).length / 200),
- tags: [],
- }),
- {
- status: 200,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- }
- );
- }
- }
- } catch (jinaError) {
- console.log('Jina.ai extraction failed:', jinaError);
- }
-
- // Fallback to direct webpage fetch
- const response = await fetch(validatedUrl.toString(), {
- headers: {
- 'User-Agent':
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
- Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
- 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
- },
- signal: AbortSignal.timeout(15000), // 15 second timeout
- });
-
- if (!response.ok) {
- return new Response(
- JSON.stringify({ error: `Failed to fetch URL: ${response.status} ${response.statusText}` }),
- {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- }
- );
- }
-
- const html = await response.text();
-
- // Parse HTML and extract content
- const doc = new DOMParser().parseFromString(html, 'text/html');
-
- if (!doc) {
- return new Response(JSON.stringify({ error: 'Failed to parse HTML' }), {
- status: 500,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Try to remove common cookie banners and overlays
- const elementsToRemove = [
- // Cookie banners
- '[class*="cookie"]',
- '[id*="cookie"]',
- '[class*="consent"]',
- '[id*="consent"]',
- '[class*="gdpr"]',
- '[id*="gdpr"]',
- '[class*="privacy"]',
- '[id*="privacy-banner"]',
- // Overlays
- '[class*="overlay"]',
- '[class*="modal"]',
- '[class*="popup"]',
- // Specific patterns
- '.cookie-banner',
- '#cookie-banner',
- '.privacy-banner',
- '#privacy-banner',
- ];
-
- elementsToRemove.forEach((selector) => {
- try {
- const elements = doc.querySelectorAll(selector);
- elements.forEach((el: any) => {
- // Only remove if it looks like a banner/overlay (not main content)
- const text = el.textContent || '';
- if (
- text.toLowerCase().includes('cookie') ||
- text.toLowerCase().includes('datenschutz') ||
- text.toLowerCase().includes('privacy') ||
- text.toLowerCase().includes('consent')
- ) {
- el.remove();
- }
- });
- } catch (e) {
- // Ignore selector errors
- }
- });
-
- // Use Readability to extract article content
- const reader = new Readability(doc);
- const article = reader.parse();
-
- if (!article) {
- // Fallback: Try to extract content manually
- let content = '';
- let title = '';
-
- // Try to find title
- const titleElement = doc.querySelector('h1') || doc.querySelector('title');
- if (titleElement) {
- title = titleElement.textContent?.trim() || '';
- }
-
- // Try to find main content areas
- const contentSelectors = [
- 'main',
- 'article',
- '[role="main"]',
- '.content',
- '#content',
- '.post',
- '.entry-content',
- '.article-content',
- '.main-content',
- ];
-
- for (const selector of contentSelectors) {
- const element = doc.querySelector(selector);
- if (element && element.textContent) {
- content = element.textContent.trim();
- break;
- }
- }
-
- // If still no content, get all paragraphs
- if (!content) {
- const paragraphs = doc.querySelectorAll('p');
- const texts: string[] = [];
- paragraphs.forEach((p: any) => {
- const text = p.textContent?.trim();
- if (text && text.length > 50) {
- // Filter out short paragraphs
- texts.push(text);
- }
- });
- content = texts.join('\n\n');
- }
-
- if (!content || content.length < 100) {
- return new Response(
- JSON.stringify({ error: 'Could not extract meaningful article content' }),
- {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- }
- );
- }
-
- // Create a pseudo-article object
- return new Response(
- JSON.stringify({
- title: title || 'Untitled',
- content: content,
- excerpt: content.substring(0, 200),
- source: validatedUrl.toString(),
- domain: validatedUrl.hostname,
- author: '',
- publishDate: '',
- wordCount: content.split(/\s+/).length,
- readingTime: Math.ceil(content.split(/\s+/).length / 200),
- tags: [],
- }),
- {
- status: 200,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- }
- );
- }
-
- // Extract additional metadata
- const metaTags = doc.querySelectorAll('meta');
- const metadata: Record = {};
-
- metaTags.forEach((meta: any) => {
- const name = meta.getAttribute('name') || meta.getAttribute('property');
- const content = meta.getAttribute('content');
-
- if (name && content) {
- if (name.includes('author')) metadata.author = content;
- if (name.includes('description')) metadata.description = content;
- if (name.includes('keywords')) metadata.keywords = content;
- if (name.includes('publish')) metadata.publishDate = content;
- }
- });
-
- // Generate tags from keywords if available
- const tags = metadata.keywords
- ? metadata.keywords
- .split(',')
- .map((k) => k.trim())
- .filter((k) => k.length > 0)
- .slice(0, 5)
- : [];
-
- // Clean and format the extracted text
- const cleanedContent = article.textContent
- .replace(/\s+/g, ' ')
- .replace(/\n{3,}/g, '\n\n')
- .trim();
-
- return new Response(
- JSON.stringify({
- title: article.title || 'Untitled',
- content: cleanedContent,
- excerpt: article.excerpt || metadata.description || '',
- source: validatedUrl.toString(),
- domain: validatedUrl.hostname,
- author: article.byline || metadata.author || '',
- publishDate: metadata.publishDate || '',
- wordCount: cleanedContent.split(/\s+/).length,
- readingTime: Math.ceil(cleanedContent.split(/\s+/).length / 200), // Assuming 200 words per minute
- tags,
- }),
- {
- status: 200,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- }
- );
- } catch (error) {
- console.error('Extract URL error:', error);
- return new Response(JSON.stringify({ error: error.message || 'Internal server error' }), {
- status: 500,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-});
diff --git a/apps/reader/apps/mobile/supabase/functions/generate-audio/index.ts b/apps/reader/apps/mobile/supabase/functions/generate-audio/index.ts
deleted file mode 100644
index a668e6857..000000000
--- a/apps/reader/apps/mobile/supabase/functions/generate-audio/index.ts
+++ /dev/null
@@ -1,512 +0,0 @@
-import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
-import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
-
-const corsHeaders = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
-};
-
-interface AudioRequest {
- textId: string;
- content: string;
- voice: string;
- provider: 'google' | 'elevenlabs' | 'openai';
- speed: number;
- chunkSize?: number;
- versionId?: string;
-}
-
-interface AudioChunk {
- id: string;
- start: number;
- end: number;
- content: string;
-}
-
-serve(async (req) => {
- // Handle CORS preflight requests
- if (req.method === 'OPTIONS') {
- return new Response('ok', { headers: corsHeaders });
- }
-
- try {
- // Parse request first to get provider
- const requestData: AudioRequest = await req.json();
- const { provider = 'google' } = requestData;
-
- // Check required environment variables based on provider
- let apiKeyPresent = false;
- let missingKeyMessage = '';
-
- switch (provider) {
- case 'google':
- apiKeyPresent = !!Deno.env.get('GOOGLE_TTS_API_KEY');
- missingKeyMessage = 'Missing GOOGLE_TTS_API_KEY environment variable';
- break;
- case 'elevenlabs':
- apiKeyPresent = !!Deno.env.get('ELEVENLABS_API_KEY');
- missingKeyMessage = 'Missing ELEVENLABS_API_KEY environment variable';
- break;
- case 'openai':
- apiKeyPresent = !!Deno.env.get('OPENAI_API_KEY');
- missingKeyMessage = 'Missing OPENAI_API_KEY environment variable';
- break;
- }
-
- if (!apiKeyPresent) {
- console.error(missingKeyMessage);
- return new Response(JSON.stringify({ error: 'TTS service not configured' }), {
- status: 500,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Initialize Supabase client
- const supabaseClient = createClient(
- Deno.env.get('SUPABASE_URL') ?? '',
- Deno.env.get('SUPABASE_ANON_KEY') ?? '',
- {
- global: {
- headers: { Authorization: req.headers.get('Authorization')! },
- },
- }
- );
-
- // Get user from JWT token
- const {
- data: { user },
- } = await supabaseClient.auth.getUser();
-
- if (!user) {
- return new Response(JSON.stringify({ error: 'Unauthorized' }), {
- status: 401,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- const { textId, content, voice, speed, chunkSize = 1000, versionId } = requestData;
-
- // Validate input
- if (!textId || !content) {
- return new Response(JSON.stringify({ error: 'Missing required fields' }), {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Split text into chunks
- const chunks: AudioChunk[] = [];
- for (let i = 0; i < content.length; i += chunkSize) {
- chunks.push({
- id: `chunk-${chunks.length}`,
- start: i,
- end: Math.min(i + chunkSize, content.length),
- content: content.slice(i, Math.min(i + chunkSize, content.length)),
- });
- }
-
- // Generate audio based on the provider
- let audioResult;
-
- switch (provider) {
- case 'elevenlabs':
- audioResult = await generateElevenLabsTTS(chunks, voice, speed);
- break;
- case 'openai':
- audioResult = await generateOpenAITTS(chunks, voice, speed);
- break;
- case 'google':
- default:
- audioResult = await generateGoogleTTS(chunks, voice, speed);
- break;
- }
-
- const { audioChunks, totalSize } = audioResult;
-
- // Store audio chunks in Supabase Storage
- const storedChunks = [];
- for (const chunkData of audioChunks) {
- try {
- // Use versionId in path if provided, otherwise use default path
- const fileName = versionId
- ? `${user.id}/${textId}/${versionId}/${chunkData.id}.mp3`
- : `${user.id}/${textId}/${chunkData.id}.mp3`;
-
- const { error: uploadError } = await supabaseClient.storage
- .from('audio')
- .upload(fileName, chunkData.audioBuffer, {
- contentType: 'audio/mpeg',
- upsert: true,
- });
-
- if (uploadError) {
- console.error('Upload error:', uploadError);
- throw uploadError;
- }
-
- // Create audio chunk metadata for storage
- storedChunks.push({
- id: chunkData.id,
- start: chunkData.start,
- end: chunkData.end,
- filename: fileName,
- size: chunkData.size,
- duration: chunkData.duration,
- createdAt: new Date().toISOString(),
- });
- } catch (error) {
- console.error(`Error storing chunk ${chunkData.id}:`, error);
- // Continue with other chunks, but log the error
- }
- }
-
- // Update text record with audio metadata
- const { error: updateError } = await supabaseClient
- .from('texts')
- .update({
- data: {
- audio: {
- hasLocalCache: false, // Will be set to true when downloaded to device
- chunks: storedChunks,
- totalSize,
- lastGenerated: new Date().toISOString(),
- settings: { voice, speed, provider },
- },
- },
- })
- .eq('id', textId)
- .eq('user_id', user.id);
-
- if (updateError) {
- throw updateError;
- }
-
- return new Response(
- JSON.stringify({
- success: true,
- chunksGenerated: storedChunks.length,
- totalSize,
- chunks: storedChunks,
- provider,
- }),
- {
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- }
- );
- } catch (error) {
- console.error('Error in generate-audio function:', error);
- return new Response(JSON.stringify({ error: error.message }), {
- status: 500,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-});
-
-function extractLanguageCode(voiceId: string): string {
- // Extract language code from voice ID (e.g., "de-DE" from "de-DE-Neural2-G")
- const parts = voiceId.split('-');
- if (parts.length >= 2) {
- return `${parts[0]}-${parts[1]}`;
- }
- return 'de-DE'; // Default fallback
-}
-
-function getVoiceName(voiceId: string): string {
- // If it's already a full voice ID (contains more than just language code), return it
- if (voiceId.includes('-') && voiceId.split('-').length > 2) {
- return voiceId;
- }
-
- // Legacy support: map old language codes to default voices
- const legacyVoiceMap: Record = {
- 'de-DE': 'de-DE-Neural2-A',
- 'en-US': 'en-US-Neural2-A',
- 'en-GB': 'en-GB-Neural2-A',
- };
-
- return legacyVoiceMap[voiceId] || 'de-DE-Neural2-A';
-}
-
-function estimateAudioDuration(text: string, speed: number): number {
- // Rough estimate: 150 words per minute for normal speech
- const wordsPerMinute = 150 * speed;
- const wordCount = text.split(/\s+/).length;
- return Math.ceil((wordCount / wordsPerMinute) * 60);
-}
-
-// Google Cloud TTS Implementation
-async function generateGoogleTTS(chunks: AudioChunk[], voice: string, speed: number) {
- const googleApiKey = Deno.env.get('GOOGLE_TTS_API_KEY');
- if (!googleApiKey) {
- throw new Error('Google TTS API key not configured');
- }
-
- const audioChunks = [];
- let totalSize = 0;
-
- for (const chunk of chunks) {
- let retries = 0;
- const maxRetries = 3;
- let delay = 1000; // Start with 1 second delay
-
- while (retries < maxRetries) {
- try {
- const ttsResponse = await fetch(
- `https://texttospeech.googleapis.com/v1/text:synthesize?key=${googleApiKey}`,
- {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- input: { text: chunk.content },
- voice: {
- languageCode: extractLanguageCode(voice),
- name: getVoiceName(voice),
- },
- audioConfig: {
- audioEncoding: 'MP3',
- speakingRate: speed,
- pitch: 0,
- volumeGainDb: 0,
- },
- }),
- }
- );
-
- if (ttsResponse.status === 429 || ttsResponse.status === 503) {
- retries++;
- if (retries < maxRetries) {
- console.log(
- `Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
- );
- await new Promise((resolve) => setTimeout(resolve, delay));
- delay *= 2; // Exponential backoff
- continue;
- } else {
- throw new Error(
- `Google TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
- );
- }
- }
-
- if (!ttsResponse.ok) {
- const errorBody = await ttsResponse.text();
- console.error('Google TTS API Error:', {
- status: ttsResponse.status,
- body: errorBody,
- });
- throw new Error(`Google TTS error: ${ttsResponse.status}`);
- }
-
- const ttsData = await ttsResponse.json();
- const audioContent = ttsData.audioContent;
- const audioBuffer = Uint8Array.from(atob(audioContent), (c) => c.charCodeAt(0));
- const audioSize = audioBuffer.length;
-
- totalSize += audioSize;
- audioChunks.push({
- id: chunk.id,
- start: chunk.start,
- end: chunk.end,
- audioBuffer,
- size: audioSize,
- duration: estimateAudioDuration(chunk.content, speed),
- });
- break; // Success, exit retry loop
- } catch (error) {
- retries++;
- console.error(
- `Error processing Google TTS chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
- error
- );
- if (retries >= maxRetries) {
- throw error; // Re-throw after all retries exhausted
- }
- await new Promise((resolve) => setTimeout(resolve, delay));
- delay *= 2; // Exponential backoff for other errors too
- }
- }
- }
-
- return { audioChunks, totalSize };
-}
-
-// ElevenLabs TTS Implementation
-async function generateElevenLabsTTS(chunks: AudioChunk[], voice: string, speed: number) {
- const elevenLabsApiKey = Deno.env.get('ELEVENLABS_API_KEY');
- if (!elevenLabsApiKey) {
- throw new Error('ElevenLabs API key not configured');
- }
-
- const audioChunks = [];
- let totalSize = 0;
-
- // Map voice IDs to ElevenLabs voice IDs
- const voiceMapping: Record = {
- eleven_multilingual_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel
- eleven_multilingual_v1: 'pNInz6obpgDQGcFmaJgB', // Adam
- eleven_turbo_v2: '21m00Tcm4TlvDq8ikWAM', // Rachel Turbo
- eleven_monolingual_v1: '2EiwWnXFnvU5JabPnv8n', // Clyde
- };
-
- const elevenLabsVoiceId = voiceMapping[voice] || '21m00Tcm4TlvDq8ikWAM';
-
- for (const chunk of chunks) {
- let retries = 0;
- const maxRetries = 3;
- let delay = 1000; // Start with 1 second delay
-
- while (retries < maxRetries) {
- try {
- const ttsResponse = await fetch(
- `https://api.elevenlabs.io/v1/text-to-speech/${elevenLabsVoiceId}`,
- {
- method: 'POST',
- headers: {
- 'xi-api-key': elevenLabsApiKey,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- text: chunk.content,
- model_id: voice.includes('turbo') ? 'eleven_turbo_v2' : 'eleven_multilingual_v2',
- voice_settings: {
- stability: 0.5,
- similarity_boost: 0.5,
- style: 0.5,
- use_speaker_boost: true,
- },
- }),
- }
- );
-
- if (ttsResponse.status === 429 || ttsResponse.status === 503) {
- retries++;
- if (retries < maxRetries) {
- console.log(
- `Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
- );
- await new Promise((resolve) => setTimeout(resolve, delay));
- delay *= 2; // Exponential backoff
- continue;
- } else {
- throw new Error(
- `ElevenLabs TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
- );
- }
- }
-
- if (!ttsResponse.ok) {
- throw new Error(`ElevenLabs TTS error: ${ttsResponse.status}`);
- }
-
- const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
- const audioSize = audioBuffer.length;
-
- totalSize += audioSize;
- audioChunks.push({
- id: chunk.id,
- start: chunk.start,
- end: chunk.end,
- audioBuffer,
- size: audioSize,
- duration: estimateAudioDuration(chunk.content, speed),
- });
- break; // Success, exit retry loop
- } catch (error) {
- retries++;
- console.error(
- `Error processing ElevenLabs chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
- error
- );
- if (retries >= maxRetries) {
- throw error; // Re-throw after all retries exhausted
- }
- await new Promise((resolve) => setTimeout(resolve, delay));
- delay *= 2; // Exponential backoff for other errors too
- }
- }
- }
-
- return { audioChunks, totalSize };
-}
-
-// OpenAI TTS Implementation
-async function generateOpenAITTS(chunks: AudioChunk[], voice: string, speed: number) {
- const openaiApiKey = Deno.env.get('OPENAI_API_KEY');
- if (!openaiApiKey) {
- throw new Error('OpenAI API key not configured');
- }
-
- const audioChunks = [];
- let totalSize = 0;
-
- for (const chunk of chunks) {
- let retries = 0;
- const maxRetries = 3;
- let delay = 1000; // Start with 1 second delay
-
- while (retries < maxRetries) {
- try {
- const ttsResponse = await fetch('https://api.openai.com/v1/audio/speech', {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${openaiApiKey}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- model: 'tts-1-hd', // Using HD model for better quality
- input: chunk.content,
- voice: voice,
- speed: speed,
- }),
- });
-
- if (ttsResponse.status === 429) {
- retries++;
- if (retries < maxRetries) {
- console.log(
- `Rate limited on chunk ${chunk.id}, retrying in ${delay}ms (attempt ${retries}/${maxRetries})`
- );
- await new Promise((resolve) => setTimeout(resolve, delay));
- delay *= 2; // Exponential backoff
- continue;
- } else {
- throw new Error(
- `OpenAI TTS error: ${ttsResponse.status} - Rate limit exceeded after ${maxRetries} attempts`
- );
- }
- }
-
- if (!ttsResponse.ok) {
- throw new Error(`OpenAI TTS error: ${ttsResponse.status}`);
- }
-
- const audioBuffer = new Uint8Array(await ttsResponse.arrayBuffer());
- const audioSize = audioBuffer.length;
-
- totalSize += audioSize;
- audioChunks.push({
- id: chunk.id,
- start: chunk.start,
- end: chunk.end,
- audioBuffer,
- size: audioSize,
- duration: estimateAudioDuration(chunk.content, speed),
- });
- break; // Success, exit retry loop
- } catch (error) {
- retries++;
- console.error(
- `Error processing OpenAI chunk ${chunk.id} (attempt ${retries}/${maxRetries}):`,
- error
- );
- if (retries >= maxRetries) {
- throw error; // Re-throw after all retries exhausted
- }
- await new Promise((resolve) => setTimeout(resolve, delay));
- delay *= 2; // Exponential backoff for other errors too
- }
- }
- }
-
- return { audioChunks, totalSize };
-}
diff --git a/apps/reader/apps/mobile/supabase/functions/get-audio-url/index.ts b/apps/reader/apps/mobile/supabase/functions/get-audio-url/index.ts
deleted file mode 100644
index 39d0049ce..000000000
--- a/apps/reader/apps/mobile/supabase/functions/get-audio-url/index.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
-import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
-
-const corsHeaders = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
-};
-
-interface AudioUrlRequest {
- textId: string;
- chunkId: string;
-}
-
-serve(async (req) => {
- // Handle CORS preflight requests
- if (req.method === 'OPTIONS') {
- return new Response('ok', { headers: corsHeaders });
- }
-
- try {
- // Initialize Supabase client
- const supabaseClient = createClient(
- Deno.env.get('SUPABASE_URL') ?? '',
- Deno.env.get('SUPABASE_ANON_KEY') ?? '',
- {
- global: {
- headers: { Authorization: req.headers.get('Authorization')! },
- },
- }
- );
-
- // Get user from JWT token
- const {
- data: { user },
- } = await supabaseClient.auth.getUser();
-
- if (!user) {
- return new Response(JSON.stringify({ error: 'Unauthorized' }), {
- status: 401,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- const { textId, chunkId }: AudioUrlRequest = await req.json();
-
- // Validate input
- if (!textId || !chunkId) {
- return new Response(JSON.stringify({ error: 'Missing required fields' }), {
- status: 400,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Verify text belongs to user
- const { data: text, error: textError } = await supabaseClient
- .from('texts')
- .select('data')
- .eq('id', textId)
- .eq('user_id', user.id)
- .single();
-
- if (textError || !text) {
- return new Response(JSON.stringify({ error: 'Text not found' }), {
- status: 404,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Find the chunk
- const chunk = text.data.audio?.chunks?.find((c: any) => c.id === chunkId);
- if (!chunk) {
- return new Response(JSON.stringify({ error: 'Chunk not found' }), {
- status: 404,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-
- // Generate signed URL for the audio file with user-specific path
- const filePath = `${user.id}/${textId}/${chunkId}.mp3`;
- const { data: urlData, error: urlError } = await supabaseClient.storage
- .from('audio')
- .createSignedUrl(filePath, 3600); // 1 hour expiration
-
- if (urlError) {
- throw urlError;
- }
-
- return new Response(
- JSON.stringify({
- success: true,
- url: urlData.signedUrl,
- chunk: {
- id: chunk.id,
- start: chunk.start,
- end: chunk.end,
- duration: chunk.duration,
- },
- }),
- {
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- }
- );
- } catch (error) {
- console.error('Error in get-audio-url function:', error);
- return new Response(JSON.stringify({ error: error.message }), {
- status: 500,
- headers: { ...corsHeaders, 'Content-Type': 'application/json' },
- });
- }
-});
diff --git a/apps/reader/apps/mobile/supabase/migrations/20240116_create_texts_table.sql b/apps/reader/apps/mobile/supabase/migrations/20240116_create_texts_table.sql
deleted file mode 100644
index 2fec5eaea..000000000
--- a/apps/reader/apps/mobile/supabase/migrations/20240116_create_texts_table.sql
+++ /dev/null
@@ -1,62 +0,0 @@
--- Enable UUID extension
-CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-
--- Die einzige Tabelle die du brauchst
-CREATE TABLE texts (
- id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
- user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
-
- -- Der eigentliche Content
- title TEXT NOT NULL,
- content TEXT NOT NULL,
-
- -- ALLES andere in einem JSONB Feld
- data JSONB DEFAULT '{}' NOT NULL,
-
- -- Nur die absolut nötigen Timestamps
- created_at TIMESTAMPTZ DEFAULT NOW(),
- updated_at TIMESTAMPTZ DEFAULT NOW()
-);
-
--- Indizes für Performance
-CREATE INDEX idx_texts_user ON texts(user_id);
-CREATE INDEX idx_texts_data ON texts USING GIN (data);
-
--- RLS aktivieren
-ALTER TABLE texts ENABLE ROW LEVEL SECURITY;
-
--- Jeder sieht nur seine eigenen Texte
-CREATE POLICY "Own texts only" ON texts
- FOR ALL USING (auth.uid() = user_id);
-
--- Update Timestamp Trigger
-CREATE OR REPLACE FUNCTION update_updated_at()
-RETURNS TRIGGER AS $$
-BEGIN
- NEW.updated_at = NOW();
- RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE TRIGGER update_texts_updated_at
- BEFORE UPDATE ON texts
- FOR EACH ROW
- EXECUTE FUNCTION update_updated_at();
-
--- Hilfsfunktion für atomare Play Count Updates
-CREATE OR REPLACE FUNCTION increment_play_count(text_id UUID)
-RETURNS void AS $$
-BEGIN
- UPDATE texts
- SET data = jsonb_set(
- jsonb_set(
- data,
- '{stats,playCount}',
- to_jsonb(COALESCE((data->'stats'->>'playCount')::int, 0) + 1)
- ),
- '{tts,lastPlayed}',
- to_jsonb(NOW())
- )
- WHERE id = text_id AND user_id = auth.uid();
-END;
-$$ LANGUAGE plpgsql;
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/supabase/migrations/20240117_create_audio_storage.sql b/apps/reader/apps/mobile/supabase/migrations/20240117_create_audio_storage.sql
deleted file mode 100644
index c9f74174b..000000000
--- a/apps/reader/apps/mobile/supabase/migrations/20240117_create_audio_storage.sql
+++ /dev/null
@@ -1,31 +0,0 @@
--- Create audio storage bucket
-INSERT INTO storage.buckets (id, name, public)
-VALUES ('audio', 'audio', false);
-
--- Create policy for authenticated users to upload their own audio files
-CREATE POLICY "Users can upload their own audio files" ON storage.objects
-FOR INSERT WITH CHECK (
- bucket_id = 'audio' AND
- auth.uid()::text = (storage.foldername(name))[1]
-);
-
--- Create policy for authenticated users to view their own audio files
-CREATE POLICY "Users can view their own audio files" ON storage.objects
-FOR SELECT USING (
- bucket_id = 'audio' AND
- auth.uid()::text = (storage.foldername(name))[1]
-);
-
--- Create policy for authenticated users to update their own audio files
-CREATE POLICY "Users can update their own audio files" ON storage.objects
-FOR UPDATE USING (
- bucket_id = 'audio' AND
- auth.uid()::text = (storage.foldername(name))[1]
-);
-
--- Create policy for authenticated users to delete their own audio files
-CREATE POLICY "Users can delete their own audio files" ON storage.objects
-FOR DELETE USING (
- bucket_id = 'audio' AND
- auth.uid()::text = (storage.foldername(name))[1]
-);
\ No newline at end of file
diff --git a/apps/reader/apps/mobile/tailwind.config.js b/apps/reader/apps/mobile/tailwind.config.js
deleted file mode 100644
index 8f31c0e00..000000000
--- a/apps/reader/apps/mobile/tailwind.config.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-module.exports = {
- content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}', './hooks/**/*.{js,ts,tsx}'],
- darkMode: 'class',
- presets: [require('nativewind/preset')],
- theme: {
- extend: {},
- },
- plugins: [],
-};
diff --git a/apps/reader/apps/mobile/tsconfig.json b/apps/reader/apps/mobile/tsconfig.json
deleted file mode 100644
index de988058c..000000000
--- a/apps/reader/apps/mobile/tsconfig.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "extends": "expo/tsconfig.base",
- "compilerOptions": {
- "strict": true,
- "jsx": "react-jsx",
- "baseUrl": ".",
- "paths": {
- "~/*": ["*"]
- }
- },
- "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
-}
diff --git a/apps/reader/apps/mobile/types/database.ts b/apps/reader/apps/mobile/types/database.ts
deleted file mode 100644
index 660ea41a8..000000000
--- a/apps/reader/apps/mobile/types/database.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-export interface Text {
- id: string;
- user_id: string;
- title: string;
- content: string;
- data: TextData;
- created_at: string;
- updated_at: string;
-}
-
-export interface TextData {
- // Vorlese-Einstellungen
- tts?: {
- speed: number;
- voice: string;
- lastPosition?: number;
- lastPlayed?: string;
- };
-
- // Legacy Audio-Cache (für Abwärtskompatibilität)
- audio?: {
- hasLocalCache: boolean;
- chunks: AudioChunk[];
- totalSize: number;
- lastGenerated?: string;
- settings?: {
- voice: string;
- speed: number;
- };
- };
-
- // Neue Audio-Versionen
- audioVersions?: AudioVersion[];
- currentAudioVersion?: string; // ID der aktiven Version
-
- // Organisation
- tags?: string[];
- color?: string;
-
- // Statistiken
- stats?: {
- playCount: number;
- totalTime: number;
- completed: boolean;
- };
-
- // Zusätzliche Felder
- notes?: string;
- source?: string;
- bookmarks?: Bookmark[];
-}
-
-export interface AudioVersion {
- id: string; // z.B. "v1-1736979654989"
- chunks: AudioChunk[];
- settings: {
- voice: string;
- speed: number;
- };
- totalSize: number;
- hasLocalCache: boolean;
- createdAt: string;
-}
-
-export interface AudioChunk {
- id: string;
- start: number;
- end: number;
- filename: string;
- size: number;
- duration: number;
- createdAt: string;
-}
-
-export interface Bookmark {
- position: number;
- note?: string;
- created: string;
-}
diff --git a/apps/reader/apps/mobile/utils/audioMigration.ts b/apps/reader/apps/mobile/utils/audioMigration.ts
deleted file mode 100644
index 36d24b65c..000000000
--- a/apps/reader/apps/mobile/utils/audioMigration.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { TextData, AudioVersion } from '~/types/database';
-
-/**
- * Migriert alte Audio-Daten zum neuen audioVersions Format
- */
-export function migrateAudioData(data: TextData): TextData {
- // Wenn bereits audioVersions existiert, keine Migration nötig
- if (data.audioVersions && data.audioVersions.length > 0) {
- return data;
- }
-
- // Wenn alte audio Daten existieren, migriere sie
- if (data.audio && data.audio.chunks && data.audio.chunks.length > 0) {
- const versionId = `v1-${data.audio.lastGenerated ? new Date(data.audio.lastGenerated).getTime() : Date.now()}`;
- const audioVersion: AudioVersion = {
- id: versionId,
- chunks: data.audio.chunks,
- settings: data.audio.settings || {
- voice: data.tts?.voice || 'de-DE-Neural2-A',
- speed: data.tts?.speed || 1,
- },
- totalSize: data.audio.totalSize,
- hasLocalCache: data.audio.hasLocalCache,
- createdAt: data.audio.lastGenerated || new Date().toISOString(),
- };
-
- return {
- ...data,
- audioVersions: [audioVersion],
- currentAudioVersion: versionId,
- };
- }
-
- // Keine Audio-Daten vorhanden
- return data;
-}
-
-/**
- * Holt die aktuelle Audio-Version basierend auf currentAudioVersion
- */
-export function getCurrentAudioVersion(data: TextData): AudioVersion | null {
- if (!data.audioVersions || data.audioVersions.length === 0) {
- return null;
- }
-
- if (data.currentAudioVersion) {
- const version = data.audioVersions.find((v) => v.id === data.currentAudioVersion);
- if (version) return version;
- }
-
- // Fallback: nimm die neueste Version
- return data.audioVersions[data.audioVersions.length - 1];
-}
-
-/**
- * Generiert eine neue Versions-ID
- */
-export function generateVersionId(): string {
- return `v${Date.now()}`;
-}
diff --git a/apps/reader/apps/mobile/utils/storage.ts b/apps/reader/apps/mobile/utils/storage.ts
deleted file mode 100644
index ce51f23e7..000000000
--- a/apps/reader/apps/mobile/utils/storage.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import { Platform } from 'react-native';
-
-// Platform-specific storage adapter for Supabase
-const createStorage = () => {
- // For web/SSR environments, use a no-op storage or localStorage
- if (Platform.OS === 'web') {
- // Check if we're in a browser environment
- if (typeof window !== 'undefined' && window.localStorage) {
- return {
- getItem: async (key: string) => window.localStorage.getItem(key),
- setItem: async (key: string, value: string) => window.localStorage.setItem(key, value),
- removeItem: async (key: string) => window.localStorage.removeItem(key),
- };
- } else {
- // SSR environment - return no-op storage
- return {
- getItem: async () => null,
- setItem: async () => {},
- removeItem: async () => {},
- };
- }
- }
-
- // For native platforms, use AsyncStorage
- return AsyncStorage;
-};
-
-export const storage = createStorage();
diff --git a/apps/reader/apps/mobile/utils/supabase.ts b/apps/reader/apps/mobile/utils/supabase.ts
deleted file mode 100644
index 1566404b6..000000000
--- a/apps/reader/apps/mobile/utils/supabase.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { createClient } from '@supabase/supabase-js';
-import { storage } from './storage';
-
-const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
-const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
-
-export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
- auth: {
- storage: storage,
- autoRefreshToken: true,
- persistSession: true,
- detectSessionInUrl: false,
- },
-});
diff --git a/apps/reader/package.json b/apps/reader/package.json
deleted file mode 100644
index 96f5be1c2..000000000
--- a/apps/reader/package.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "name": "@manacore/reader",
- "version": "0.0.1",
- "private": true,
- "scripts": {
- "dev": "turbo run dev"
- }
-}
diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts
index f937c9cc6..8a180fdc1 100644
--- a/packages/shared-branding/src/app-icons.ts
+++ b/packages/shared-branding/src/app-icons.ts
@@ -69,9 +69,6 @@ const questionsSvg = ``;
-// Playground icon (code/terminal with cyan gradient)
-const playgroundSvg = ``;
-
// CityCorners icon (map pin with blue gradient)
const citycornersSvg = ``;
@@ -109,7 +106,6 @@ export const APP_ICONS = {
inventory: svgToDataUrl(inventorySvg),
questions: svgToDataUrl(questionsSvg),
matrix: svgToDataUrl(matrixSvg),
- playground: svgToDataUrl(playgroundSvg),
context: svgToDataUrl(contextSvg),
citycorners: svgToDataUrl(citycornersSvg),
times: svgToDataUrl(timesSvg),
@@ -117,9 +113,6 @@ export const APP_ICONS = {
uload: svgToDataUrl(
``
),
- reader: svgToDataUrl(
- ``
- ),
news: svgToDataUrl(
``
),
diff --git a/packages/shared-branding/src/config.ts b/packages/shared-branding/src/config.ts
index 13610a426..0d1ee0992 100644
--- a/packages/shared-branding/src/config.ts
+++ b/packages/shared-branding/src/config.ts
@@ -259,18 +259,6 @@ export const APP_BRANDING: Record = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
- playground: {
- id: 'playground',
- name: 'Playground',
- tagline: 'LLM Playground',
- primaryColor: '#06b6d4',
- secondaryColor: '#22d3ee',
- // Code/terminal icon for LLM playground
- logoPath: 'M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5',
- logoViewBox: '0 0 24 24',
- logoStroke: true,
- logoStrokeWidth: 1.5,
- },
lightwrite: {
id: 'lightwrite',
name: 'LightWrite',
diff --git a/packages/shared-branding/src/logos/PlaygroundLogo.svelte b/packages/shared-branding/src/logos/PlaygroundLogo.svelte
deleted file mode 100644
index 4053abe16..000000000
--- a/packages/shared-branding/src/logos/PlaygroundLogo.svelte
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts
index 70d6c0438..bad1fe0a0 100644
--- a/packages/shared-branding/src/mana-apps.ts
+++ b/packages/shared-branding/src/mana-apps.ts
@@ -478,23 +478,6 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'alpha',
},
- {
- 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: 'planning',
- requiredTier: 'founder',
- },
{
id: 'news',
name: 'News Hub',
@@ -614,23 +597,6 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'alpha',
},
- {
- id: 'playground',
- name: 'Playground',
- description: {
- de: 'LLM Playground',
- en: 'LLM Playground',
- },
- longDescription: {
- de: 'Experimentiere mit verschiedenen KI-Modellen in einer interaktiven Spielwiese.',
- en: 'Experiment with different AI models in an interactive playground.',
- },
- icon: APP_ICONS.playground,
- color: '#06b6d4',
- comingSoon: false,
- status: 'beta',
- requiredTier: 'alpha',
- },
{
id: 'arcade',
name: 'Arcade',
@@ -758,12 +724,10 @@ export const APP_URLS: Record = {
calc: { dev: 'http://localhost:5173/calc', prod: 'https://mana.how/calc' },
moodlit: { dev: 'http://localhost:5173/moodlit', prod: 'https://mana.how/moodlit' },
memoro: { dev: 'http://localhost:5173/memoro', prod: 'https://mana.how/memoro' },
- playground: { dev: 'http://localhost:5173/playground', prod: 'https://mana.how/playground' },
guides: { dev: 'http://localhost:5173/guides', prod: 'https://mana.how/guides' },
wisekeep: { dev: 'http://localhost:5173/wisekeep', prod: 'https://mana.how/wisekeep' },
news: { dev: 'http://localhost:5173/news', prod: 'https://mana.how/news' },
mail: { dev: 'http://localhost:5173/mail', prod: 'https://mana.how/mail' },
- reader: { dev: 'exp://localhost:8081', prod: 'https://reader.mana.how' },
// ─── Separate Apps (own subdomains) ───────────────────────
matrix: { dev: 'http://localhost:5180', prod: 'https://matrix.mana.how' },
arcade: { dev: 'http://localhost:5201', prod: 'https://arcade.mana.how' },
diff --git a/packages/shared-branding/src/types.ts b/packages/shared-branding/src/types.ts
index 12a51f5aa..831ec2c69 100644
--- a/packages/shared-branding/src/types.ts
+++ b/packages/shared-branding/src/types.ts
@@ -22,7 +22,6 @@ export type AppId =
| 'questions'
| 'skilltree'
| 'planta'
- | 'playground'
| 'lightwrite'
| 'context'
| 'mukke'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f022e2327..b58b1035d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2354,6 +2354,9 @@ importers:
'@manacore/help':
specifier: workspace:*
version: link:../../../../packages/help
+ '@manacore/local-llm':
+ specifier: workspace:*
+ version: link:../../../../packages/local-llm
'@manacore/local-store':
specifier: workspace:*
version: link:../../../../packages/local-store
@@ -2435,9 +2438,9 @@ importers:
dexie:
specifier: ^4.0.11
version: 4.4.1
- leaflet:
- specifier: ^1.9.4
- version: 1.9.4
+ marked:
+ specifier: ^17.0.5
+ version: 17.0.5
svelte-dnd-action:
specifier: ^0.9.68
version: 0.9.68(svelte@5.44.0)
@@ -20561,6 +20564,11 @@ packages:
engines: {node: '>= 20'}
hasBin: true
+ marked@17.0.5:
+ resolution: {integrity: sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==}
+ engines: {node: '>= 20'}
+ hasBin: true
+
marky@1.3.0:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
@@ -47310,6 +47318,8 @@ snapshots:
marked@17.0.1: {}
+ marked@17.0.5: {}
+
marky@1.3.0: {}
math-intrinsics@1.1.0: {}