chore: remove playground, reader, bauntown, voxelava, and worldream apps

These apps were either stubs (voxelava, worldream), planning-stage
(reader), dev-tools (playground), or inactive (bauntown). Removing
to reduce monorepo surface area. All recoverable from git history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 11:47:54 +02:00
parent e5a6946d8b
commit 348b6ff231
126 changed files with 13 additions and 11273 deletions

View file

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

View file

@ -1,9 +0,0 @@
node_modules
.svelte-kit
.git
*.md
.env*
!.env.example
dist
coverage
.DS_Store

View file

@ -1,7 +0,0 @@
node_modules/
.svelte-kit/
.turbo/
.env
dist/
build/
*.log

View file

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

View file

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

View file

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

View file

@ -1,18 +0,0 @@
/// <reference types="@sveltejs/kit" />
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 {};

View file

@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -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 = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
window.__PUBLIC_MANA_LLM_URL__ = ${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -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<HealthResponse> {
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<ModelsResponse> {
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<ChatCompletionResponse> {
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<string, void, unknown> {
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();
}
}

View file

@ -1,80 +0,0 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
import { comparisonStore } from '$lib/stores/comparison.svelte';
import { Stop, PaperPlaneRight } from '@manacore/shared-icons';
let input = $state('');
let textareaEl: HTMLTextAreaElement | undefined = $state();
const isComparisonReady = $derived(
comparisonStore.comparisonMode && comparisonStore.selectedModels.length >= 2
);
function handleSubmit() {
if (!input.trim() || chatStore.isStreaming) return;
if (isComparisonReady) {
chatStore.sendComparisonMessage(input);
} else {
chatStore.sendMessage(input);
}
input = '';
if (textareaEl) {
textareaEl.style.height = 'auto';
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
function autoResize() {
if (textareaEl) {
textareaEl.style.height = 'auto';
textareaEl.style.height = Math.min(textareaEl.scrollHeight, 200) + 'px';
}
}
</script>
<div class="border-t p-4" style="border-color: var(--color-border);">
<div
class="flex items-end gap-3 rounded-xl border p-2"
style="background-color: var(--color-surface); border-color: var(--color-border);"
>
<textarea
bind:this={textareaEl}
bind:value={input}
onkeydown={handleKeydown}
oninput={autoResize}
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
disabled={chatStore.isStreaming}
rows="1"
class="max-h-[200px] min-h-[44px] flex-1 resize-none bg-transparent px-2 py-2 text-sm outline-none placeholder:text-gray-500 disabled:opacity-50"
></textarea>
{#if chatStore.isStreaming}
<button
onclick={() => chatStore.stopStreaming()}
aria-label="Stop streaming"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg transition-colors"
style="background-color: var(--color-error);"
>
<Stop size={20} weight="fill" />
</button>
{:else}
<button
onclick={handleSubmit}
disabled={!input.trim()}
aria-label="Send message"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg transition-colors disabled:opacity-50"
style="background-color: var(--color-primary);"
>
<PaperPlaneRight size={20} />
</button>
{/if}
</div>
</div>

View file

@ -1,31 +0,0 @@
<script lang="ts">
import type { ComparisonMessage } from '$lib/types';
import ComparisonResponseCard from '../comparison/ComparisonResponseCard.svelte';
let { message }: { message: ComparisonMessage } = $props();
</script>
<div class="space-y-4">
<!-- User Message -->
<div class="flex justify-end">
<div class="max-w-[80%] rounded-lg p-3" style="background-color: var(--color-primary);">
<p class="whitespace-pre-wrap text-white">{message.userContent}</p>
</div>
</div>
<!-- Comparison Label -->
<div class="flex items-center gap-2">
<div class="h-px flex-1" style="background-color: var(--color-border);"></div>
<span class="text-xs font-medium" style="color: var(--color-text-muted);">
Comparing {message.responses.length} models
</span>
<div class="h-px flex-1" style="background-color: var(--color-border);"></div>
</div>
<!-- Comparison Responses Grid -->
<div class="flex gap-4 overflow-x-auto pb-2">
{#each message.responses as response (response.modelId)}
<ComparisonResponseCard {response} />
{/each}
</div>
</div>

View file

@ -1,64 +0,0 @@
<script lang="ts">
import type { ChatMessage } from '$lib/types';
import { marked } from 'marked';
interface Props {
message: ChatMessage;
}
let { message }: Props = $props();
const renderedContent = $derived(
message.role === 'assistant' ? marked.parse(message.content, { async: false }) : message.content
);
function formatTime(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
</script>
<div class="flex {message.role === 'user' ? 'justify-end' : 'justify-start'}">
<div
class="max-w-[80%] rounded-2xl px-4 py-3 {message.role === 'user'
? 'rounded-br-md'
: 'rounded-bl-md'}"
style="background-color: {message.role === 'user'
? 'var(--color-primary)'
: 'var(--color-surface)'};"
>
{#if message.role === 'assistant'}
<div class="prose prose-invert text-sm">
{#if message.isStreaming && message.content === ''}
<div class="flex items-center gap-1">
<span class="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]"
></span>
<span class="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]"
></span>
<span class="h-2 w-2 animate-bounce rounded-full bg-current"></span>
</div>
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderedContent}
{#if message.isStreaming}
<span class="inline-block h-4 w-1 animate-pulse bg-current"></span>
{/if}
{/if}
</div>
{:else}
<p class="text-sm whitespace-pre-wrap">{message.content}</p>
{/if}
<div
class="mt-2 flex items-center gap-2 text-xs"
style="color: {message.role === 'user'
? 'rgba(255,255,255,0.7)'
: 'var(--color-text-muted)'};"
>
<span>{formatTime(message.timestamp)}</span>
{#if message.model}
<span>·</span>
<span>{message.model.split('/').pop()}</span>
{/if}
</div>
</div>
</div>

View file

@ -1,40 +0,0 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
import type { ChatMessage, ComparisonMessage } from '$lib/types';
import MessageBubble from './MessageBubble.svelte';
import ComparisonMessageBubble from './ComparisonMessageBubble.svelte';
import { ChatCircle } from '@manacore/shared-icons';
let scrollContainer: HTMLDivElement | undefined = $state();
$effect(() => {
// Scroll to bottom when messages change
if (chatStore.messages.length && scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
});
</script>
<div bind:this={scrollContainer} class="flex-1 overflow-y-auto p-4">
{#if chatStore.messages.length === 0}
<div class="flex h-full flex-col items-center justify-center">
<div class="mb-4 rounded-full p-4" style="background-color: var(--color-surface);">
<ChatCircle size={20} />
</div>
<h2 class="mb-2 text-lg font-medium">Start a conversation</h2>
<p class="max-w-md text-center text-sm" style="color: var(--color-text-muted);">
Select a model from the sidebar and send a message to begin testing the mana-llm service.
</p>
</div>
{:else}
<div class="space-y-4">
{#each chatStore.messages as message (message.id)}
{#if message.role === 'comparison'}
<ComparisonMessageBubble message={message as ComparisonMessage} />
{:else}
<MessageBubble message={message as ChatMessage} />
{/if}
{/each}
</div>
{/if}
</div>

View file

@ -1,62 +0,0 @@
<script lang="ts">
import type { ComparisonResponse } from '$lib/types';
let { response }: { response: ComparisonResponse } = $props();
const modelName = $derived(response.modelId.split('/').pop() || response.modelId);
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
</script>
<div
class="flex min-w-[280px] flex-1 flex-col rounded-lg border p-4"
style="background-color: var(--color-surface); border-color: var(--color-border);"
>
<!-- Header -->
<div
class="mb-3 flex items-center justify-between border-b pb-2"
style="border-color: var(--color-border);"
>
<span class="truncate text-sm font-medium" style="color: var(--color-text);">
{modelName}
</span>
{#if response.isStreaming}
<span class="animate-pulse rounded bg-blue-600 px-2 py-0.5 text-xs text-white">
Streaming...
</span>
{:else if response.error}
<span class="rounded bg-red-600 px-2 py-0.5 text-xs text-white"> Error </span>
{:else}
<span class="rounded bg-green-600 px-2 py-0.5 text-xs text-white"> Done </span>
{/if}
</div>
<!-- Content -->
<div class="mb-3 min-h-[100px] flex-1 overflow-y-auto text-sm" style="color: var(--color-text);">
{#if response.error}
<p class="text-red-400">{response.error}</p>
{:else}
<pre class="whitespace-pre-wrap font-sans">{response.content}</pre>
{#if response.isStreaming}
<span class="ml-1 inline-block h-4 w-2 animate-pulse bg-blue-500"></span>
{/if}
{/if}
</div>
<!-- Metrics -->
{#if response.metrics && !response.isStreaming}
<div
class="flex gap-4 border-t pt-2 text-xs"
style="border-color: var(--color-border); color: var(--color-text-muted);"
>
<span>{formatDuration(response.metrics.durationMs)}</span>
<span>~{response.metrics.completionTokens} tokens</span>
{#if response.metrics.tokensPerSecond > 0}
<span>{response.metrics.tokensPerSecond.toFixed(1)} t/s</span>
{/if}
</div>
{/if}
</div>

View file

@ -1,82 +0,0 @@
<script lang="ts">
import type { ModelWithModality, Modality } from '$lib/types';
import { comparisonStore } from '$lib/stores/comparison.svelte';
import ModelModalityFilter from './ModelModalityFilter.svelte';
let { models }: { models: ModelWithModality[] } = $props();
let selectedModality = $state<Modality>('text');
const filteredModels = $derived(models.filter((m) => m.modality === selectedModality));
function getModelDisplayName(modelId: string): string {
const parts = modelId.split('/');
return parts.length > 1 ? parts.slice(1).join('/') : modelId;
}
</script>
<div class="border-t p-4" style="border-color: var(--color-border);">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold" style="color: var(--color-text);">Model Comparison</h3>
<button
onclick={() => comparisonStore.toggleComparisonMode()}
class="rounded px-2 py-1 text-xs transition-colors"
class:bg-blue-600={comparisonStore.comparisonMode}
class:text-white={comparisonStore.comparisonMode}
style={!comparisonStore.comparisonMode
? 'background-color: var(--color-bg); color: var(--color-text-muted);'
: ''}
>
{comparisonStore.comparisonMode ? 'Active' : 'Off'}
</button>
</div>
{#if comparisonStore.comparisonMode}
<ModelModalityFilter {models} bind:selectedModality />
<div class="max-h-48 space-y-1 overflow-y-auto">
{#each filteredModels as model}
{@const isSelected = comparisonStore.isModelSelected(model.id)}
{@const isDisabled = !isSelected && !comparisonStore.canAddModel()}
<label
class="flex cursor-pointer items-center gap-2 rounded p-2 transition-colors hover:bg-zinc-800"
class:opacity-50={isDisabled}
class:cursor-not-allowed={isDisabled}
title={model.description || ''}
>
<input
type="checkbox"
checked={isSelected}
onchange={() => comparisonStore.toggleModel(model.id)}
disabled={isDisabled}
class="rounded"
/>
<div class="min-w-0 flex-1">
<span class="block truncate text-sm" style="color: var(--color-text);">
{getModelDisplayName(model.id)}
</span>
{#if model.description}
<span class="block truncate text-xs" style="color: var(--color-text-muted);">
{model.description}
</span>
{/if}
</div>
</label>
{/each}
</div>
<p class="mt-2 text-xs" style="color: var(--color-text-muted);">
{comparisonStore.selectedModels.length}/{comparisonStore.maxModels} models selected
</p>
{#if comparisonStore.selectedModels.length > 0}
<button
onclick={() => comparisonStore.clearSelection()}
class="mt-2 w-full rounded px-2 py-1 text-xs transition-colors"
style="background-color: var(--color-bg); color: var(--color-text-muted);"
>
Clear Selection
</button>
{/if}
{/if}
</div>

View file

@ -1,42 +0,0 @@
<script lang="ts">
import type { Modality, ModelWithModality } from '$lib/types';
let {
models,
selectedModality = $bindable('text'),
}: {
models: ModelWithModality[];
selectedModality: Modality;
} = $props();
const modalities: { value: Modality; label: string; icon: string }[] = [
{ value: 'text', label: 'Text', icon: 'T' },
{ value: 'vision', label: 'Vision', icon: 'V' },
{ value: 'code', label: 'Code', icon: 'C' },
];
const modelCounts = $derived(
modalities.map((m) => ({
...m,
count: models.filter((model) => model.modality === m.value).length,
}))
);
</script>
<div class="mb-3 flex gap-2">
{#each modelCounts as mod}
<button
onclick={() => (selectedModality = mod.value)}
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
class:bg-blue-600={selectedModality === mod.value}
class:text-white={selectedModality === mod.value}
style={selectedModality !== mod.value
? 'background-color: var(--color-bg); color: var(--color-text-muted);'
: ''}
>
<span class="mr-1 font-bold">{mod.icon}</span>
{mod.label}
<span class="ml-1 opacity-70">({mod.count})</span>
</button>
{/each}
</div>

View file

@ -1,82 +0,0 @@
<script lang="ts">
import { getHealth } from '$lib/api/llm';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
import { ArrowsClockwise } from '@manacore/shared-icons';
let healthStatus = $state<'loading' | 'healthy' | 'error'>('loading');
let healthDetails = $state<string>('');
async function checkHealth() {
try {
const health = await getHealth();
healthStatus = health.status === 'healthy' ? 'healthy' : 'error';
healthDetails = health.version ? `v${health.version}` : '';
} catch {
healthStatus = 'error';
healthDetails = 'Connection failed';
}
}
onMount(() => {
checkHealth();
const interval = setInterval(checkHealth, 30000);
return () => clearInterval(interval);
});
</script>
<header
class="flex h-14 items-center justify-between border-b px-4"
style="border-color: var(--color-border); background-color: var(--color-surface);"
>
<div class="flex items-center gap-3">
<svg class="h-8 w-8" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="var(--color-primary)" />
<path d="M8 16l4-8 4 8-4 8-4-8z" fill="white" opacity="0.9" />
<path d="M16 16l4-8 4 8-4 8-4-8z" fill="white" opacity="0.7" />
</svg>
<div>
<h1 class="text-lg font-semibold">LLM Playground</h1>
<p class="text-xs" style="color: var(--color-text-muted);">mana-llm Service</p>
</div>
</div>
<div class="flex items-center gap-2">
<div
class="flex items-center gap-2 rounded-full px-3 py-1 text-sm"
style="background-color: var(--color-bg);"
>
{#if healthStatus === 'loading'}
<div
class="h-2 w-2 animate-pulse rounded-full"
style="background-color: var(--color-warning);"
></div>
<span style="color: var(--color-text-muted);">Checking...</span>
{:else if healthStatus === 'healthy'}
<div class="h-2 w-2 rounded-full" style="background-color: var(--color-success);"></div>
<span style="color: var(--color-success);">Healthy</span>
{#if healthDetails}
<span style="color: var(--color-text-muted);">({healthDetails})</span>
{/if}
{:else}
<div class="h-2 w-2 rounded-full" style="background-color: var(--color-error);"></div>
<span style="color: var(--color-error);">Offline</span>
{/if}
</div>
<button
onclick={checkHealth}
class="rounded p-1.5 transition-colors hover:bg-white/10"
title="Refresh health status"
>
<ArrowsClockwise size={16} />
</button>
<button
onclick={() => authStore.signOut()}
class="rounded px-3 py-1.5 text-sm transition-colors hover:bg-white/10"
style="color: var(--color-text-muted);"
title="Abmelden"
>
Abmelden
</button>
</div>
</header>

View file

@ -1,65 +0,0 @@
<script lang="ts">
import ModelSelector from '$lib/components/settings/ModelSelector.svelte';
import ParameterPanel from '$lib/components/settings/ParameterPanel.svelte';
import SystemPromptEditor from '$lib/components/settings/SystemPromptEditor.svelte';
import ModelComparisonSelector from '$lib/components/comparison/ModelComparisonSelector.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { Trash, Export } from '@manacore/shared-icons';
function handleExport() {
const data = chatStore.exportMessages();
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-export-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleClear() {
if (chatStore.messages.length === 0) return;
if (confirm('Clear all messages?')) {
chatStore.clearMessages();
}
}
</script>
<aside
class="flex w-80 flex-col border-r"
style="border-color: var(--color-border); background-color: var(--color-surface);"
>
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-6">
<ModelSelector />
<ParameterPanel />
<SystemPromptEditor />
</div>
</div>
<ModelComparisonSelector models={modelsStore.modelsWithModality} />
<div class="border-t p-4" style="border-color: var(--color-border);">
<div class="flex gap-2">
<button
onclick={handleClear}
disabled={chatStore.messages.length === 0}
class="flex flex-1 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50"
style="background-color: var(--color-bg);"
>
<Trash size={16} />
Clear
</button>
<button
onclick={handleExport}
disabled={chatStore.messages.length === 0}
class="flex flex-1 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50"
style="background-color: var(--color-bg);"
>
<Export size={16} />
Export
</button>
</div>
</div>
</aside>

View file

@ -1,66 +0,0 @@
<script lang="ts">
import { modelsStore, MODEL_METADATA } from '$lib/stores/models.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { onMount } from 'svelte';
onMount(() => {
modelsStore.loadModels();
});
// Get description for currently selected model
const selectedModelDescription = $derived(() => {
const modelName = settingsStore.model.includes('/')
? settingsStore.model.split('/').slice(1).join('/')
: settingsStore.model;
return MODEL_METADATA[modelName]?.description;
});
</script>
<div>
<label for="model-select" class="mb-2 block text-sm font-medium">Model</label>
{#if modelsStore.loading}
<div
class="flex h-10 items-center justify-center rounded-lg"
style="background-color: var(--color-bg);"
>
<span class="text-sm" style="color: var(--color-text-muted);">Loading models...</span>
</div>
{:else if modelsStore.error}
<div class="space-y-2">
<div
class="rounded-lg p-3 text-sm"
style="background-color: rgba(239, 68, 68, 0.1); color: var(--color-error);"
>
{modelsStore.error}
</div>
<button
onclick={() => modelsStore.loadModels()}
class="w-full rounded-lg py-2 text-sm font-medium transition-colors"
style="background-color: var(--color-bg);"
>
Retry
</button>
</div>
{:else}
<select
id="model-select"
bind:value={settingsStore.model}
class="w-full rounded-lg border px-3 py-2 text-sm"
style="background-color: var(--color-bg); border-color: var(--color-border);"
>
{#each modelsStore.groupedModels as group}
<optgroup label={group.label}>
{#each group.models as model}
<option value={model.id}>{model.id.split('/').slice(1).join('/')}</option>
{/each}
</optgroup>
{/each}
</select>
<div class="mt-1.5 text-xs" style="color: var(--color-text-muted);">
{#if selectedModelDescription()}
<p class="mb-0.5">{selectedModelDescription()}</p>
{/if}
<p>{modelsStore.models.length} models available</p>
</div>
{/if}
</div>

View file

@ -1,79 +0,0 @@
<script lang="ts">
import { settingsStore } from '$lib/stores/settings.svelte';
</script>
<div class="space-y-4">
<div>
<div class="mb-2 flex items-center justify-between">
<label for="temperature-slider" class="text-sm font-medium">Temperature</label>
<span class="text-sm" style="color: var(--color-text-muted);"
>{settingsStore.temperature.toFixed(2)}</span
>
</div>
<input
id="temperature-slider"
type="range"
min="0"
max="2"
step="0.01"
bind:value={settingsStore.temperature}
class="h-2 w-full cursor-pointer appearance-none rounded-lg"
style="background: linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) {(settingsStore.temperature /
2) *
100}%, var(--color-bg) {(settingsStore.temperature / 2) * 100}%, var(--color-bg) 100%);"
/>
<div class="mt-1 flex justify-between text-xs" style="color: var(--color-text-muted);">
<span>Precise</span>
<span>Creative</span>
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<label for="max-tokens-slider" class="text-sm font-medium">Max Tokens</label>
<span class="text-sm" style="color: var(--color-text-muted);">{settingsStore.maxTokens}</span>
</div>
<input
id="max-tokens-slider"
type="range"
min="256"
max="8192"
step="256"
bind:value={settingsStore.maxTokens}
class="h-2 w-full cursor-pointer appearance-none rounded-lg"
style="background: linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) {((settingsStore.maxTokens -
256) /
(8192 - 256)) *
100}%, var(--color-bg) {((settingsStore.maxTokens - 256) / (8192 - 256)) *
100}%, var(--color-bg) 100%);"
/>
<div class="mt-1 flex justify-between text-xs" style="color: var(--color-text-muted);">
<span>256</span>
<span>8192</span>
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<label for="top-p-slider" class="text-sm font-medium">Top P</label>
<span class="text-sm" style="color: var(--color-text-muted);"
>{settingsStore.topP.toFixed(2)}</span
>
</div>
<input
id="top-p-slider"
type="range"
min="0"
max="1"
step="0.01"
bind:value={settingsStore.topP}
class="h-2 w-full cursor-pointer appearance-none rounded-lg"
style="background: linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) {settingsStore.topP *
100}%, var(--color-bg) {settingsStore.topP * 100}%, var(--color-bg) 100%);"
/>
<div class="mt-1 flex justify-between text-xs" style="color: var(--color-text-muted);">
<span>Focused</span>
<span>Diverse</span>
</div>
</div>
</div>

View file

@ -1,27 +0,0 @@
<script lang="ts">
import { settingsStore } from '$lib/stores/settings.svelte';
</script>
<div>
<div class="mb-2 flex items-center justify-between">
<label for="system-prompt" class="text-sm font-medium">System Prompt</label>
<button
onclick={() => (settingsStore.systemPrompt = 'You are a helpful AI assistant.')}
class="text-xs transition-colors"
style="color: var(--color-text-muted);"
>
Reset
</button>
</div>
<textarea
id="system-prompt"
bind:value={settingsStore.systemPrompt}
placeholder="Enter system prompt..."
rows="4"
class="w-full resize-none rounded-lg border p-3 text-sm"
style="background-color: var(--color-bg); border-color: var(--color-border);"
></textarea>
<p class="mt-1.5 text-xs" style="color: var(--color-text-muted);">
{settingsStore.systemPrompt.length} characters
</p>
</div>

View file

@ -1,7 +0,0 @@
/**
* Auth Store uses centralized Mana auth factory.
*/
import { createManaAuthStore } from '@manacore/shared-auth-stores';
export const authStore = createManaAuthStore();

View file

@ -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<AnyMessage[]>([]);
let isStreaming = $state(false);
let abortController = $state<AbortController | null>(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();

View file

@ -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<string[]>([]);
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<ComparisonResponse[]> {
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();

View file

@ -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<string, { description: string; modality: Modality }> = {
// 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<Model[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
// Models with modality information for comparison
const modelsWithModality = $derived<ModelWithModality[]>(
models.map((model) => ({
...model,
modality: detectModality(model.id),
description: getModelDescription(model.id),
}))
);
const groupedModels = $derived.by(() => {
const groups: Record<Provider, Model[]> = {
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<Provider, string> = {
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();

View file

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

View file

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

View file

@ -1,6 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
{@render children()}

View file

@ -1,51 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { PlaygroundLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
// Default to German translations
const translations = $derived(getLoginTranslations('de'));
// Read verification status from query params
const verified = $derived($page.url.searchParams.get('verified') === 'true');
const initialEmail = $derived($page.url.searchParams.get('email') || '');
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
<title>{translations.title} | LLM Playground</title>
</svelte:head>
<LoginPage
appName="LLM Playground"
logo={PlaygroundLogo}
primaryColor="#06b6d4"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
passkeyAvailable={authStore.isPasskeyAvailable()}
onSignInWithPasskey={() => 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}
/>

View file

@ -1,36 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { PlaygroundLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
// Default to German translations
const translations = $derived(getRegisterTranslations('de'));
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
async function handleResendVerification(email: string) {
return authStore.resendVerificationEmail(email);
}
</script>
<svelte:head>
<title>{translations.title} | LLM Playground</title>
</svelte:head>
<RegisterPage
appName="LLM Playground"
logo={PlaygroundLogo}
primaryColor="#06b6d4"
onSignUp={handleSignUp}
onResendVerification={handleResendVerification}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#ecfeff"
darkBackground="#083344"
{translations}
/>

View file

@ -1,34 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
let isChecking = $state(true);
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
const currentPath = $page.url.pathname;
goto(`/login?redirectTo=${encodeURIComponent(currentPath)}`);
return;
}
isChecking = false;
});
</script>
{#if isChecking}
<div
class="min-h-screen flex items-center justify-center"
style="background-color: var(--color-bg);"
>
<div
class="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2"
style="border-color: var(--color-primary);"
></div>
</div>
{:else}
{@render children()}
{/if}

View file

@ -1,21 +0,0 @@
<script lang="ts">
import Header from '$lib/components/layout/Header.svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import MessageList from '$lib/components/chat/MessageList.svelte';
import ChatInput from '$lib/components/chat/ChatInput.svelte';
</script>
<svelte:head>
<title>LLM Playground | mana-llm</title>
</svelte:head>
<Header />
<div class="flex flex-1 overflow-hidden">
<Sidebar />
<main class="flex flex-1 flex-col" style="background-color: var(--color-bg);">
<MessageList />
<ChatInput />
</main>
</div>

View file

@ -1,13 +0,0 @@
<script lang="ts">
import '../app.css';
interface Props {
children: import('svelte').Snippet;
}
let { children }: Props = $props();
</script>
<div class="flex h-screen flex-col" style="background-color: var(--color-bg);">
{@render children()}
</div>

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 B

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
{
"name": "playground",
"private": true,
"scripts": {
"dev": "turbo run dev"
}
}

View file

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

View file

@ -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
<View className="flex-1 items-center justify-center">
<Text className="text-lg font-bold">Hello</Text>
</View>
```
### State Management (Zustand)
```tsx
import { useStore } from '~/store/store';
const { state, actions } = useStore();
```
### Supabase Client
```tsx
// Client configured in apps/mobile/utils/supabase.ts
import { supabase } from '~/utils/supabase';
```
### Path Alias
Use `~/*` for absolute imports from mobile root:
```tsx
import { Button } from '~/components/Button';
```
## Environment Variables
Create `apps/reader/apps/mobile/.env`:
```bash
EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
## Current Implementation Status
- [x] Expo Router setup with tab navigation
- [x] Supabase integration
- [x] Zustand store (user state, settings, audio player)
- [x] NativeWind styling
- [x] User authentication (Login, Register, Forgot Password)
- [x] Text management UI (List, Add, View, Delete)
- [x] Settings screen
- [x] Text-to-Speech with Google Cloud TTS
- [x] Audio player with progress tracking
- [x] Offline audio storage (Expo FileSystem)
- [x] Tag system with filtering
- [x] Supabase Edge Functions for audio generation
- [x] Audio chunk system for large texts
- [x] Local audio caching
## Detailed Documentation
- `apps/mobile/ReadMe/ProjectOverview.md` - Project vision (German)
- `apps/mobile/ReadMe/MinimalDatabase.md` - Database design
- `apps/mobile/docs/` - Additional documentation

View file

@ -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
<ActionMenu
options={[
{ title: 'Open', systemIcon: 'doc.text' },
{ title: 'Share', systemIcon: 'square.and.arrow.up' },
{ title: 'Delete', systemIcon: 'trash', destructive: true },
]}
onSelect={(index) => {
// Handle selection
}}
>
<YourComponent />
</ActionMenu>
```
## Benefits:
- Works with Expo Go (no development build required)
- Native feel on iOS
- Consistent experience across platforms
- No native dependencies
## If you need a development build:
If you prefer to use the native context menu and are willing to use a development build:
1. Re-install `react-native-context-menu-view`
2. Run `npx expo prebuild`
3. Run `npx expo run:ios` or `npx expo run:android`
The current solution allows you to continue using Expo Go for development while providing a good user experience.

View file

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

View file

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

View file

@ -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 (
<View>
<Text>{text.title}</Text>
{!hasCache && (
<Button
title="Audio generieren & speichern"
onPress={handleGenerateAudio}
disabled={generating}
/>
)}
{generating && <ActivityIndicator />}
{hasCache && (
<>
<Text>Audio gespeichert: {(text.data.audio.totalSize / 1024 / 1024).toFixed(2)} MB</Text>
<Button title="Offline abspielen" onPress={handlePlay} />
<Button title="Cache löschen" onPress={() => clearAudioCache(text.id)} />
</>
)}
</View>
);
};
```
## Vorteile dieser Struktur
**Eine Tabelle** = Keine Joins, keine Komplexität
**JSONB** = Unendlich erweiterbar ohne Migrations
**Performance** = PostgreSQL's JSONB ist super schnell
**Einfach** = Jeder versteht es sofort
**Flexibel** = Neue Features sind nur ein JSON-Feld entfernt
## Erweiterungsbeispiele
```javascript
// Später: Lesezeichen hinzufügen
data.bookmarks = [{ position: 1234, note: 'Wichtige Stelle', created: '2024-01-15' }];
// Später: Sharing hinzufügen
data.sharing = {
isPublic: false,
shareToken: 'abc123',
sharedWith: ['email@example.com'],
};
// Später: AI-Features
data.ai = {
summary: 'KI-generierte Zusammenfassung',
keywords: ['Thema1', 'Thema2'],
difficulty: 'medium',
};
```
## Das war's! 🎉
Mit dieser einen Tabelle kannst du:
- Texte speichern ✓
- Vorlesen mit gespeicherter Position ✓
- Tags/Kategorien verwalten ✓
- Statistiken tracken ✓
- Beliebig erweitern ✓
Keine zweite Tabelle nötig. Kein Over-Engineering. Einfach machen.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.tabBarActive,
tabBarInactiveTintColor: colors.tabBarInactive,
tabBarStyle: {
backgroundColor: colors.tabBarBackground,
borderTopColor: colors.tabBarBorder,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Texte',
tabBarIcon: ({ color }) => <TabBarIcon name="book" color={color} />,
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Einstellungen',
tabBarIcon: ({ color }) => <TabBarIcon name="cog" color={color} />,
}}
/>
</Tabs>
);
}

View file

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

View file

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

View file

@ -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 (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View file

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

View file

@ -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 (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
</Stack>
);
}

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View file

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

View file

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

View file

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

View file

@ -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<AudioPlayerProps> = ({ text, onAudioGenerated }) => {
const [isGenerating, setIsGenerating] = useState(false);
const [showSpeedControl, setShowSpeedControl] = useState(false);
const [selectedVoice, setSelectedVoice] = useState<string>('');
const [showVersions, setShowVersions] = useState(false);
const progressBarRef = useRef<View>(null);
const pulseAnim = useRef(new Animated.Value(1)).current;
const { settings, updateSettings } = useStore();
const { colors } = useTheme();
// Use useMemo to prevent re-migration on every render
const migratedData = useMemo(() => migrateAudioData(text.data), [text.data]);
const audioVersions = migratedData.audioVersions || [];
const currentVersion = useMemo(() => getCurrentAudioVersion(migratedData), [migratedData]);
// Initialize selectedVersionId with current version
const [selectedVersionId, setSelectedVersionId] = useState<string>(currentVersion?.id || '');
// Initialize selected voice
useEffect(() => {
setSelectedVoice(settings.voice);
}, [settings.voice]);
const {
audioState,
generationProgress,
generateAudio,
playAudio,
pauseAudio,
resumeAudio,
stopAudio,
seekTo,
seekForward,
seekBackward,
setPlaybackSpeed,
clearCache,
} = useAudio();
// Pulsating animation for loading state
useEffect(() => {
if (audioState.isLoading) {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.2,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
])
).start();
} else {
pulseAnim.setValue(1);
}
}, [audioState.isLoading, pulseAnim]);
const handleGenerateAudio = async () => {
try {
setIsGenerating(true);
await generateAudio(text.id, text.content, selectedVoice, settings.speed, text);
onAudioGenerated?.();
Alert.alert(
'Audio generiert!',
'Das Audio wurde erfolgreich generiert und ist jetzt verfügbar.'
);
} catch (error) {
Alert.alert(
'Fehler',
error instanceof Error ? error.message : 'Fehler beim Generieren des Audios'
);
} finally {
setIsGenerating(false);
}
};
const handleVoiceChange = (newVoice: string) => {
setSelectedVoice(newVoice);
// Update the global settings
updateSettings({ voice: newVoice });
};
const handlePlayPause = async () => {
if (!selectedVersion?.chunks) return;
try {
if (audioState.isPlaying) {
await pauseAudio();
} else if (audioState.sound) {
await resumeAudio();
} else {
// Play directly from Supabase Storage
await playAudio(text.id, selectedVersion.chunks, text.data.tts?.lastPosition || 0);
}
} catch (error) {
Alert.alert(
'Wiedergabe-Fehler',
error instanceof Error ? error.message : 'Fehler beim Abspielen des Audios'
);
}
};
const handleStop = async () => {
await stopAudio();
};
const formatTime = (milliseconds: number): string => {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const formatSize = (bytes: number): string => {
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
};
const speedOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
const handleSpeedChange = async (speed: number) => {
await setPlaybackSpeed(speed);
setShowSpeedControl(false);
};
// Use duration from audio state if available, otherwise calculate from chunks
const totalDuration =
audioState.duration ||
(selectedVersion?.chunks
? selectedVersion.chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000
: 0);
// Handle progress bar press
const handleProgressPress = async (event: any) => {
if (progressBarRef.current && totalDuration > 0) {
progressBarRef.current.measure(async (x, y, width, height, pageX, pageY) => {
const touchX = event.nativeEvent.pageX - pageX;
const progress = Math.max(0, Math.min(1, touchX / width));
const newPosition = progress * totalDuration;
// If audio hasn't been started yet, start it at the desired position
if (!audioState.sound) {
await playAudio(text.id, text.data.audio!.chunks, newPosition);
} else {
await seekTo(newPosition);
}
});
}
};
// Get the selected audio version
const selectedVersion = audioVersions.find((v) => v.id === selectedVersionId) || currentVersion;
const hasAudio = selectedVersion && selectedVersion.chunks.length > 0;
return (
<View className={`rounded-lg ${colors.surface} p-3 shadow-sm`}>
{/* Voice selection and generate button - always visible */}
<View className="mb-4">
<Text className={`mb-2 text-sm font-medium ${colors.textSecondary}`}>Sprachauswahl</Text>
<Dropdown
options={[]}
value={selectedVoice}
onValueChange={handleVoiceChange}
placeholder="Wähle eine Stimme"
disabled={isGenerating}
title="Stimme auswählen"
groups={Object.entries(
GERMAN_VOICES.reduce(
(groups, voice) => {
const provider = voice.provider;
const quality = voice.quality;
if (!groups[provider]) {
groups[provider] = {};
}
if (!groups[provider][quality]) {
groups[provider][quality] = [];
}
groups[provider][quality].push(voice);
return groups;
},
{} as Record<string, Record<string, typeof GERMAN_VOICES>>
)
).map(([provider, qualityGroups]) => ({
title: PROVIDER_LABELS[provider as keyof typeof PROVIDER_LABELS],
options: Object.entries(qualityGroups).flatMap(([quality, voices]) =>
voices.map((voice) => ({
label: `${QUALITY_LABELS[quality as keyof typeof QUALITY_LABELS]} - ${voice.label}`,
value: voice.value,
}))
),
}))}
/>
<Pressable
onPress={handleGenerateAudio}
disabled={isGenerating}
className={`mt-3 rounded-lg px-4 py-2.5 ${isGenerating ? 'bg-gray-400' : colors.primary}`}
>
{isGenerating ? (
<View className="flex-row items-center justify-center">
<ActivityIndicator size="small" color="white" />
<Text className="ml-2 font-medium text-white">
{generationProgress?.currentChunk || 'Generiere Audio...'}
</Text>
</View>
) : (
<View className="flex-row items-center justify-center">
<Ionicons name="volume-high" size={20} color="white" />
<Text className="ml-2 font-medium text-white">
{hasAudio ? 'Audio neu generieren' : 'Audio generieren'}
</Text>
</View>
)}
</Pressable>
{generationProgress && (
<View className="mt-2">
<View className={`h-1.5 rounded-full ${colors.surfaceSecondary}`}>
<View
className={`h-1.5 rounded-full ${colors.primary}`}
style={{
width: `${(generationProgress.chunksCompleted / generationProgress.totalChunks) * 100}%`,
}}
/>
</View>
<Text className={`mt-1 text-xs ${colors.textSecondary}`}>
{generationProgress.chunksCompleted} / {generationProgress.totalChunks} Chunks
</Text>
</View>
)}
</View>
{/* Audio versions - only shown when audio exists */}
{audioVersions.length > 0 && (
<View className="mt-4">
<Pressable
onPress={() => setShowVersions(!showVersions)}
className="flex-row items-center justify-between"
>
<Text className={`text-sm font-medium ${colors.textSecondary}`}>
Audio-Versionen ({audioVersions.length})
</Text>
<Ionicons
name={showVersions ? 'chevron-up' : 'chevron-down'}
size={16}
color="#71717a"
/>
</Pressable>
{showVersions && (
<ScrollView className="mt-2 max-h-40">
{audioVersions.map((version) => {
const voice = getVoiceById(version.settings.voice);
const isActive = version.id === selectedVersionId;
const date = new Date(version.createdAt);
return (
<Pressable
key={version.id}
onPress={() => setSelectedVersionId(version.id)}
className={`mb-2 rounded-lg p-3 ${
isActive ? 'bg-blue-600' : colors.surfaceSecondary
}`}
>
<View className="flex-row items-center justify-between">
<View className="flex-1">
<Text className={`text-sm ${isActive ? 'text-white' : colors.text}`}>
{date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
<Text
className={`text-xs ${isActive ? 'text-blue-100' : colors.textSecondary}`}
>
{voice?.label || version.settings.voice} {version.settings.speed}x
</Text>
</View>
<View className="flex-row items-center">
{isActive && <Text className="mr-2 text-xs text-white">Aktiv</Text>}
<Ionicons
name={isActive ? 'radio-button-on' : 'radio-button-off'}
size={20}
color={isActive ? 'white' : '#71717a'}
/>
</View>
</View>
</Pressable>
);
})}
</ScrollView>
)}
</View>
)}
{/* Audio player - only shown when audio exists */}
{hasAudio && (
<View className="mt-4 border-t border-zinc-800 pt-3">
{/* </View> closing tag moved to end */}
{/* Progress bar and time info - full width */}
<View className="mb-3">
{/* Progress Bar with touch gestures */}
<Pressable onPress={handleProgressPress} className="py-2">
<View
ref={progressBarRef}
className={`h-2 rounded-full ${colors.surfaceSecondary} overflow-hidden`}
>
<View
className={`h-2 rounded-full ${colors.primary}`}
style={{
width:
totalDuration > 0
? `${(audioState.currentPosition / totalDuration) * 100}%`
: '0%',
}}
/>
{/* Scrubber indicator */}
{totalDuration > 0 && (
<View
className="absolute top-0 h-2"
style={{
left: `${(audioState.currentPosition / totalDuration) * 100}%`,
}}
>
<View
className={`h-3 w-3 rounded-full ${colors.primary} shadow-lg`}
style={{ marginTop: -2, marginLeft: -6 }}
/>
</View>
)}
</View>
</Pressable>
{/* Time display */}
<View className="mt-1 flex-row justify-between">
<Text className={`text-xs ${colors.textTertiary}`}>
{formatTime(audioState.currentPosition)}
</Text>
<Text className={`text-xs ${colors.textTertiary}`}>{formatTime(totalDuration)}</Text>
</View>
</View>
{/* Controls row */}
<View className="flex-row items-center justify-center">
{/* Stop button */}
<Pressable
onPress={handleStop}
disabled={audioState.isLoading}
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}
>
<Ionicons name="stop" size={18} color="#6b7280" />
</Pressable>
{/* Backward 15s button */}
<Pressable
onPress={() => seekBackward(15)}
disabled={audioState.isLoading || !audioState.sound}
className={`rounded-full ${colors.surfaceSecondary} mr-2 p-2`}
>
<View className="relative" style={{ transform: [{ scaleX: -1 }] }}>
<Ionicons name="reload" size={18} color="#6b7280" />
<View
className="absolute -bottom-1 -left-1"
style={{ transform: [{ scaleX: -1 }] }}
>
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
</View>
</View>
</Pressable>
{/* Play/Pause button */}
<Animated.View
style={{
transform: [{ scale: audioState.isLoading ? pulseAnim : 1 }],
}}
>
<Pressable
onPress={handlePlayPause}
disabled={audioState.isLoading}
className={`rounded-full ${colors.primary} mx-2 p-2.5`}
>
{audioState.isLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<Ionicons
name={audioState.isPlaying ? 'pause' : 'play'}
size={20}
color="white"
/>
)}
</Pressable>
</Animated.View>
{/* Forward 15s button */}
<Pressable
onPress={() => seekForward(15)}
disabled={audioState.isLoading || !audioState.sound}
className={`rounded-full ${colors.surfaceSecondary} mr-3 p-2`}
>
<View className="relative">
<Ionicons name="reload" size={18} color="#6b7280" />
<View className="absolute -bottom-1 -right-1">
<Text style={{ fontSize: 8, color: '#6b7280', fontWeight: 'bold' }}>15</Text>
</View>
</View>
</Pressable>
{/* Speed control button */}
<Pressable
onPress={() => setShowSpeedControl(!showSpeedControl)}
className={`rounded-full ${colors.surfaceSecondary} px-3 py-1.5`}
>
<Text style={{ fontSize: 14, color: '#6b7280', fontWeight: '600' }}>
{audioState.playbackRate}x
</Text>
</Pressable>
</View>
{/* Speed options dropdown */}
{showSpeedControl && (
<View className="mt-2 flex-row justify-center">
<View className={`rounded-lg ${colors.surfaceSecondary} flex-row p-2`}>
{speedOptions.map((speed) => (
<Pressable
key={speed}
onPress={() => handleSpeedChange(speed)}
className={`mx-1 rounded px-3 py-1 ${
audioState.playbackRate === speed ? colors.primary : ''
}`}
>
<Text
style={{
fontSize: 12,
color: audioState.playbackRate === speed ? '#ffffff' : '#6b7280',
fontWeight: audioState.playbackRate === speed ? 'bold' : 'normal',
}}
>
{speed}x
</Text>
</Pressable>
))}
</View>
</View>
)}
</View>
)}
</View>
);
};

View file

@ -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<PressableProps, 'children'> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: IconName;
iconPosition?: 'left' | 'right';
loading?: boolean;
disabled?: boolean;
fullWidth?: boolean;
className?: string;
children?: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
icon,
iconPosition = 'left',
loading = false,
disabled = false,
fullWidth = false,
className,
children,
...props
}) => {
const isDisabled = disabled || loading;
// Get variant styles
const getVariantClasses = () => {
switch (variant) {
case 'primary':
return 'bg-blue-600 active:bg-blue-700';
case 'secondary':
return 'bg-gray-600 active:bg-gray-700';
case 'outline':
return 'border border-gray-300 bg-white active:bg-gray-50';
case 'ghost':
return 'bg-transparent active:bg-gray-100';
case 'link':
return 'bg-transparent';
case 'destructive':
return 'bg-red-600 active:bg-red-700';
case 'success':
return 'bg-green-600 active:bg-green-700';
case 'warning':
return 'bg-yellow-600 active:bg-yellow-700';
default:
return 'bg-blue-600 active:bg-blue-700';
}
};
// Get size styles
const getSizeClasses = () => {
switch (size) {
case 'xs':
return 'px-2 py-1';
case 'sm':
return 'px-3 py-2';
case 'md':
return 'px-4 py-3';
case 'lg':
return 'px-6 py-4';
case 'xl':
return 'px-8 py-5';
default:
return 'px-4 py-3';
}
};
// Get text color
const getTextColor = () => {
if (isDisabled) return 'muted';
switch (variant) {
case 'primary':
case 'secondary':
case 'destructive':
case 'success':
case 'warning':
return 'white';
case 'outline':
case 'ghost':
return 'gray';
case 'link':
return 'primary';
default:
return 'white';
}
};
// Get icon color
const getIconColor = () => {
if (isDisabled) return '#9CA3AF';
switch (variant) {
case 'primary':
case 'secondary':
case 'destructive':
case 'success':
case 'warning':
return '#FFFFFF';
case 'outline':
case 'ghost':
return '#6B7280';
case 'link':
return '#2563EB';
default:
return '#FFFFFF';
}
};
// Get icon size
const getIconSize = () => {
switch (size) {
case 'xs':
return 14;
case 'sm':
return 16;
case 'md':
return 18;
case 'lg':
return 20;
case 'xl':
return 24;
default:
return 18;
}
};
// Get text variant
const getTextVariant = () => {
switch (size) {
case 'xs':
case 'sm':
return 'buttonSmall';
default:
return 'button';
}
};
const renderContent = () => {
if (loading) {
return <ActivityIndicator size="small" color={getIconColor()} />;
}
const iconElement = icon ? (
<Icon name={icon} size={getIconSize()} color={getIconColor()} />
) : null;
const textElement = children ? (
<Text variant={getTextVariant()} color={getTextColor()} align="center">
{children}
</Text>
) : null;
if (!icon && !children) {
return null;
}
if (icon && !children) {
return iconElement;
}
if (!icon && children) {
return textElement;
}
return (
<View className="flex-row items-center gap-2">
{iconPosition === 'left' && iconElement}
{textElement}
{iconPosition === 'right' && iconElement}
</View>
);
};
const buttonClasses = [
'rounded-lg items-center justify-center',
getSizeClasses(),
getVariantClasses(),
fullWidth ? 'w-full' : '',
isDisabled ? 'opacity-50' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<Pressable className={buttonClasses} disabled={isDisabled} {...props}>
{renderContent()}
</Pressable>
);
};

View file

@ -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<View>(null);
const { colors } = useTheme();
const handleLongPress = () => {
childRef.current?.measure((x, y, width, height, pageX, pageY) => {
const screenHeight = Dimensions.get('window').height;
const menuHeight = actions.length * 50 + 20; // Approximate menu height
// Position menu above or below the pressed item based on available space
const posY = pageY + height + menuHeight > screenHeight ? pageY - menuHeight : pageY + height;
setMenuPosition({ x: pageX, y: posY });
setVisible(true);
});
};
const handleActionPress = (index: number) => {
setVisible(false);
// Small delay to allow modal to close before action
setTimeout(() => onPress(index), 100);
};
const iconMap: Record<string, keyof typeof Ionicons.glyphMap> = {
'doc.text': 'document-text-outline',
'play.circle': 'play-circle-outline',
'square.and.arrow.up': 'share-outline',
tag: 'pricetag-outline',
trash: 'trash-outline',
};
const renderAction = ({ item, index }: { item: ContextMenuAction; index: number }) => {
const iconName = item.icon || (item.systemIcon ? iconMap[item.systemIcon] : undefined);
const isDisabled = item.disabled;
const isDestructive = item.destructive;
return (
<Pressable
onPress={() => !isDisabled && handleActionPress(index)}
disabled={isDisabled}
className={`flex-row items-center px-4 py-3 ${
index < actions.length - 1 ? `border-b ${colors.border}` : ''
}`}
style={({ pressed }) => ({
backgroundColor: pressed && !isDisabled ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
opacity: isDisabled ? 0.5 : 1,
})}
>
{iconName && (
<Ionicons
name={iconName}
size={20}
color={
isDestructive ? '#EF4444' : colors.text.includes('white') ? '#FFFFFF' : '#111827'
}
style={{ marginRight: 12 }}
/>
)}
<Text className={`text-base ${isDestructive ? 'text-red-500' : colors.text}`}>
{item.title}
</Text>
</Pressable>
);
};
return (
<>
<View ref={childRef} collapsable={false}>
{React.cloneElement(children, {
onLongPress: handleLongPress,
delayLongPress: 500,
} as any)}
</View>
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={() => setVisible(false)}
>
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => setVisible(false)}>
<View style={[styles.backdrop, { backgroundColor: 'rgba(0, 0, 0, 0.3)' }]} />
<View
style={[
styles.menu,
{
top: menuPosition.y,
left: 20,
right: 20,
maxWidth: 300,
alignSelf: 'center',
backgroundColor: colors.text.includes('white') ? '#1f2937' : '#ffffff',
},
]}
className={`rounded-lg shadow-lg ${colors.surface}`}
>
<FlatList
data={actions}
renderItem={renderAction}
keyExtractor={(_, index) => index.toString()}
scrollEnabled={false}
/>
</View>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
},
menu: {
position: 'absolute',
borderRadius: 12,
overflow: 'hidden',
...Platform.select({
ios: {
// @ts-ignore - React Native Web supports boxShadow
boxShadow: '0px 2px 10px rgba(0, 0, 0, 0.25)',
},
android: {
elevation: 8,
},
}),
},
});

View file

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

View file

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

View file

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

View file

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

View file

@ -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<MinimalAudioPlayerProps> = ({ text }) => {
const [isGenerating, setIsGenerating] = useState(false);
const { currentTextId } = useStore();
const { colors } = useTheme();
const { audioState, generateAudio, playAudio, pauseAudio, resumeAudio, stopAudio } = useAudio();
// Check if this text is currently playing
const isCurrentText = currentTextId === text.id;
const isPlaying = isCurrentText && audioState.isPlaying;
const isLoading = isCurrentText && audioState.isLoading;
// Get audio version
const migratedData = migrateAudioData(text.data);
const currentVersion = getCurrentAudioVersion(migratedData);
const hasAudio = currentVersion && currentVersion.chunks.length > 0;
// Stop audio when component unmounts or text changes
useEffect(() => {
return () => {
if (isCurrentText) {
stopAudio();
}
};
}, [isCurrentText, stopAudio]);
const handlePlayPause = async () => {
if (!hasAudio) {
// Generate audio if not available
try {
setIsGenerating(true);
const { settings } = useStore.getState();
await generateAudio(text.id, text.content, settings.voice, settings.speed, text);
} catch (error) {
console.error('Error generating audio:', error);
} finally {
setIsGenerating(false);
}
return;
}
try {
if (isPlaying) {
await pauseAudio();
} else if (isCurrentText && audioState.sound) {
await resumeAudio();
} else {
// Stop any other playing audio and start this one
if (currentTextId && currentTextId !== text.id) {
await stopAudio();
}
await playAudio(text.id, currentVersion.chunks, 0);
}
} catch (error) {
console.error('Error playing audio:', error);
}
};
return (
<Pressable
onPress={handlePlayPause}
disabled={isLoading || isGenerating}
className={`rounded-full p-2 ${
hasAudio ? colors.surfaceSecondary : colors.surface
} active:opacity-70`}
>
{isLoading || isGenerating ? (
<ActivityIndicator size="small" color={colors.tabBarInactive} />
) : (
<Icon
name={hasAudio ? (isPlaying ? 'pause-circle' : 'play-circle') : 'mic-circle'}
size={28}
color={hasAudio ? colors.tabBarActive : colors.tabBarInactive}
/>
)}
</Pressable>
);
};

View file

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

View file

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

View file

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

View file

@ -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<TextVariant, string> = {
h1: 'text-4xl font-bold',
h2: 'text-3xl font-bold',
h3: 'text-2xl font-bold',
h4: 'text-xl font-bold',
h5: 'text-lg font-bold',
h6: 'text-base font-bold',
body: 'text-base',
bodyLarge: 'text-lg',
bodySmall: 'text-sm',
caption: 'text-xs',
label: 'text-sm font-medium',
labelLarge: 'text-base font-medium',
labelSmall: 'text-xs font-medium',
button: 'text-base font-semibold',
buttonSmall: 'text-sm font-semibold',
overline: 'text-xs font-medium uppercase tracking-wide',
subtitle1: 'text-base font-medium',
subtitle2: 'text-sm font-medium',
};
const colorStyles: Record<TextColor, string> = {
primary: 'text-blue-600',
secondary: 'text-gray-600',
accent: 'text-purple-600',
error: 'text-red-600',
warning: 'text-yellow-600',
success: 'text-green-600',
info: 'text-blue-500',
white: 'text-white',
black: 'text-black',
gray: 'text-gray-500',
muted: 'text-gray-400',
red: 'text-red-600',
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
purple: 'text-purple-600',
pink: 'text-pink-600',
indigo: 'text-indigo-600',
cyan: 'text-cyan-600',
orange: 'text-orange-600',
};
const weightStyles: Record<string, string> = {
light: 'font-light',
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
const alignStyles: Record<string, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
justify: 'text-justify',
};
export const Text: React.FC<TextComponentProps> = ({
variant = 'body',
color = 'inherit',
weight,
align,
className,
children,
...props
}) => {
const { colors } = useTheme();
// Map semantic colors to theme colors
const getThemeColor = (textColor: TextColor): string => {
switch (textColor) {
case 'inherit':
case 'primary':
return colors.text;
case 'secondary':
return colors.textSecondary;
case 'tertiary':
case 'muted':
return colors.textTertiary;
default:
return colorStyles[textColor] || colors.text;
}
};
const variantClass = variantStyles[variant];
const colorClass = getThemeColor(color);
const weightClass = weight ? weightStyles[weight] : '';
const alignClass = align ? alignStyles[align] : '';
const combinedClassName = [variantClass, colorClass, weightClass, alignClass, className]
.filter(Boolean)
.join(' ');
return (
<RNText className={combinedClassName} {...props}>
{children}
</RNText>
);
};

View file

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

View file

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

View file

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

View file

@ -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": ["<all_urls>"],
"js": ["content.js"]
}
]
}
```
### Content Script
```javascript
// content.js
function extractArticle() {
// Nutze Readability direkt im Browser
const documentClone = document.cloneNode(true);
const reader = new Readability(documentClone);
const article = reader.parse();
if (article) {
// Sende an Reader App
const readerUrl = `reader-app://add?title=${encodeURIComponent(article.title)}&content=${encodeURIComponent(article.content)}`;
window.location.href = readerUrl;
}
}
```
### Integration in React Native
```typescript
// Deep Link Handler
import { Linking } from 'react-native';
Linking.addEventListener('url', (event) => {
const url = new URL(event.url);
if (url.protocol === 'reader-app:' && url.pathname === 'add') {
const title = url.searchParams.get('title');
const content = url.searchParams.get('content');
// Erstelle neuen Text
}
});
```
## iOS Share Extension Alternative
### Info.plist
```xml
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY(extensionItems, $e, SUBQUERY($e.attachments, $a, $a.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url").@count > 0).@count > 0</string>
</dict>
</dict>
```
### Share Extension Code
```swift
import MobileCoreServices
class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
let provider = item.attachments?.first {
if provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
provider.loadItem(forTypeIdentifier: kUTTypeURL as String) { (url, error) in
if let shareURL = url as? URL {
// Extrahiere Content mit WKWebView
self.extractContent(from: shareURL)
}
}
}
}
}
func extractContent(from url: URL) {
let webView = WKWebView()
webView.load(URLRequest(url: url))
// Nach dem Laden JavaScript ausführen
webView.evaluateJavaScript("document.body.innerText") { (result, error) in
if let text = result as? String {
// Speichere in App Group oder sende an App
self.saveToApp(title: url.host ?? "Artikel", content: text, url: url.absoluteString)
}
}
}
}
```
## Vorteile
1. Umgeht alle Cookie-Banner (Nutzer akzeptiert im Browser)
2. Zugriff auf den vollständig gerenderten Content
3. Native Integration in iOS/Android Share-Menü
4. Kein Server-Side Rendering nötig
## Nachteile
1. Zusätzliche Installation erforderlich
2. Platform-spezifische Entwicklung
3. App Store Review Process für Extensions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<AudioState>({
isPlaying: false,
isLoading: false,
currentPosition: 0,
duration: 0,
playbackRate: settings.playbackRate || 1.0,
});
const [generationProgress, setGenerationProgress] = useState<AudioGenerationProgress | null>(
null
);
const [downloadProgress, setDownloadProgress] = useState<{
completed: number;
total: number;
currentChunk: string;
} | null>(null);
const { setCurrentText, setIsPlaying, setCurrentPosition } = useStore();
const audioService = AudioService.getInstance();
// Initialize audio session
useEffect(() => {
const initializeAudio = async () => {
try {
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
shouldDuckAndroid: true,
staysActiveInBackground: true,
playThroughEarpieceAndroid: false,
});
} catch (error) {
console.error('Error initializing audio:', error);
}
};
initializeAudio();
}, []);
// Clean up audio when component unmounts
useEffect(() => {
return () => {
if (audioState.sound) {
audioState.sound.unloadAsync();
}
};
}, [audioState.sound]);
// Generate audio for a text
const generateAudio = useCallback(
async (
textId: string,
content: string,
voice: string = 'de-DE',
speed: number = 1.0,
currentText?: any
) => {
try {
setGenerationProgress({
chunksCompleted: 0,
totalChunks: 1,
currentChunk: 'Starting...',
isComplete: false,
});
// Import migration helper
const { generateVersionId } = await import('~/utils/audioMigration');
const newVersionId = generateVersionId();
const result = await audioService.generateAudioForText(
textId,
content,
voice,
speed,
1000,
setGenerationProgress,
newVersionId
);
if (!result.success) {
throw new Error(result.error);
}
// Get current text to append to audioVersions
if (!currentText) {
throw new Error('Text must be provided to generate audio');
}
// Import migration helper for existing code
const { migrateAudioData } = await import('~/utils/audioMigration');
// Migrate old data if needed
const migratedData = migrateAudioData(currentText.data);
const newAudioVersion = {
id: newVersionId,
chunks: result.chunks || [],
settings: { voice, speed },
totalSize: result.chunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
hasLocalCache: false,
createdAt: new Date().toISOString(),
};
// Append new version to audioVersions
const updatedAudioVersions = [...(migratedData.audioVersions || []), newAudioVersion];
// Update text with new audio version
await updateText(textId, {
data: {
...migratedData,
audioVersions: updatedAudioVersions,
currentAudioVersion: newVersionId,
// Keep legacy audio field for backward compatibility
audio: {
hasLocalCache: false,
chunks: result.chunks || [],
totalSize: newAudioVersion.totalSize,
lastGenerated: newAudioVersion.createdAt,
settings: { voice, speed },
},
},
});
return result;
} catch (error) {
console.error('Error generating audio:', error);
throw error;
} finally {
setGenerationProgress(null);
}
},
[audioService, updateText]
);
// Download audio chunks to local storage
const downloadAudio = useCallback(
async (textId: string, chunks: AudioChunk[]) => {
try {
setDownloadProgress({
completed: 0,
total: chunks.length,
currentChunk: 'Starting download...',
});
const result = await audioService.downloadAudioChunks(textId, chunks, setDownloadProgress);
if (!result.success) {
throw new Error(result.error);
}
// Update text to mark as locally cached
await updateText(textId, {
data: {
audio: {
hasLocalCache: true,
chunks: result.localChunks || chunks,
totalSize: result.localChunks?.reduce((sum, chunk) => sum + chunk.size, 0) || 0,
lastGenerated: new Date().toISOString(),
},
},
});
return result;
} catch (error) {
console.error('Error downloading audio:', error);
throw error;
} finally {
setDownloadProgress(null);
}
},
[audioService, updateText]
);
// Play audio from local cache
const playAudio = useCallback(
async (textId: string, chunks: AudioChunk[], startPosition: number = 0) => {
try {
setAudioState((prev) => ({ ...prev, isLoading: true }));
// Stop current audio if playing
if (audioState.sound) {
audioState.sound.unloadAsync();
}
// Calculate total duration from all chunks
const totalDuration = chunks.reduce((sum, chunk) => sum + chunk.duration, 0) * 1000; // Convert to milliseconds
const result = await audioService.playAudioFromSupabase(textId, chunks, startPosition);
if (!result.sound) {
throw new Error(result.error);
}
const currentChunk = result.chunk;
const allChunks = result.chunks || chunks;
// Set up playback status update
result.sound.setOnPlaybackStatusUpdate((status) => {
if (status.isLoaded) {
// Calculate the actual position across all chunks
const chunkPosition = status.positionMillis || 0;
const overallPosition = currentChunk
? currentChunk.start + chunkPosition
: chunkPosition;
setAudioState((prev) => ({
...prev,
isPlaying: status.isPlaying,
currentPosition: overallPosition,
duration: totalDuration, // Keep using total duration
}));
// Update global store
setIsPlaying(status.isPlaying);
setCurrentPosition(overallPosition);
}
});
setAudioState((prev) => ({
...prev,
sound: result.sound,
isLoading: false,
isPlaying: true,
duration: totalDuration, // Set total duration of all chunks
currentChunk: currentChunk,
}));
setCurrentText(textId);
// Start playing
await result.sound.playAsync();
// Apply saved playback rate
if (audioState.playbackRate !== 1.0) {
await result.sound.setRateAsync(audioState.playbackRate, true);
}
} catch (error) {
console.error('Error playing audio:', error);
setAudioState((prev) => ({ ...prev, isLoading: false }));
throw error;
}
},
[
audioState.sound,
audioState.playbackRate,
audioService,
setCurrentText,
setIsPlaying,
setCurrentPosition,
]
);
// Pause audio
const pauseAudio = useCallback(async () => {
if (audioState.sound) {
await audioState.sound.pauseAsync();
setAudioState((prev) => ({ ...prev, isPlaying: false }));
setIsPlaying(false);
}
}, [audioState.sound, setIsPlaying]);
// Resume audio
const resumeAudio = useCallback(async () => {
if (audioState.sound) {
await audioState.sound.playAsync();
setAudioState((prev) => ({ ...prev, isPlaying: true }));
setIsPlaying(true);
}
}, [audioState.sound, setIsPlaying]);
// Stop audio
const stopAudio = useCallback(async () => {
if (audioState.sound) {
await audioState.sound.pauseAsync();
await audioState.sound.unloadAsync();
setAudioState((prev) => ({
...prev,
sound: undefined,
isPlaying: false,
currentPosition: 0,
duration: 0,
}));
setCurrentText(null);
setIsPlaying(false);
setCurrentPosition(0);
}
}, [audioState.sound, setCurrentText, setIsPlaying, setCurrentPosition]);
// Seek to position
const seekTo = useCallback(
async (position: number) => {
if (audioState.sound) {
await audioState.sound.setPositionAsync(position);
}
},
[audioState.sound]
);
// Seek forward by seconds
const seekForward = useCallback(
async (seconds: number = 15) => {
if (audioState.sound && audioState.duration > 0) {
const newPosition = Math.min(
audioState.currentPosition + seconds * 1000,
audioState.duration
);
await audioState.sound.setPositionAsync(newPosition);
}
},
[audioState.sound, audioState.currentPosition, audioState.duration]
);
// Seek backward by seconds
const seekBackward = useCallback(
async (seconds: number = 15) => {
if (audioState.sound) {
const newPosition = Math.max(audioState.currentPosition - seconds * 1000, 0);
await audioState.sound.setPositionAsync(newPosition);
}
},
[audioState.sound, audioState.currentPosition]
);
// Set playback speed
const setPlaybackSpeed = useCallback(
async (rate: number) => {
if (audioState.sound) {
try {
await audioState.sound.setRateAsync(rate, true);
setAudioState((prev) => ({ ...prev, playbackRate: rate }));
// Persist to store
updateSettings({ playbackRate: rate });
} catch (error) {
console.error('Error setting playback rate:', error);
}
} else {
// If no sound is playing, just update the state for next playback
setAudioState((prev) => ({ ...prev, playbackRate: rate }));
// Persist to store
updateSettings({ playbackRate: rate });
}
},
[audioState.sound, updateSettings]
);
// Clear audio cache
const clearCache = useCallback(
async (textId: string, chunks: AudioChunk[]) => {
await audioService.clearAudioCache(textId, chunks);
// Update text to mark as not cached
await updateText(textId, {
data: {
audio: {
hasLocalCache: false,
chunks,
totalSize: 0,
},
},
});
},
[audioService, updateText]
);
// Get cache size
const getCacheSize = useCallback(async () => {
return await audioService.getCacheSize();
}, [audioService]);
// Check if audio is cached
const isAudioCached = useCallback(
async (textId: string, chunks: AudioChunk[]) => {
return await audioService.isAudioCached(textId, chunks);
},
[audioService]
);
return {
audioState,
generationProgress,
downloadProgress,
generateAudio,
downloadAudio,
playAudio,
pauseAudio,
resumeAudio,
stopAudio,
seekTo,
seekForward,
seekBackward,
setPlaybackSpeed,
clearCache,
getCacheSize,
isAudioCached,
};
};

View file

@ -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<Session | null>(null);
const [loading, setLoading] = useState(true);
const { setUser } = useStore();
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session?.user) {
setUser({
id: session.user.id,
email: session.user.email!,
});
}
setLoading(false);
});
// Listen for auth changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
if (session?.user) {
setUser({
id: session.user.id,
email: session.user.email!,
});
} else {
setUser(null);
}
});
return () => subscription.unsubscribe();
}, [setUser]);
const signUp = async (email: string, password: string) => {
try {
const { data, error } = await supabase.auth.signUp({
email,
password,
});
if (error) throw error;
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error.message : 'Fehler bei der Registrierung',
};
}
};
const signIn = async (email: string, password: string) => {
try {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error.message : 'Fehler beim Anmelden',
};
}
};
const signOut = async () => {
try {
const { error } = await supabase.auth.signOut();
if (error) throw error;
return { error: null };
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Fehler beim Abmelden',
};
}
};
const resetPassword = async (email: string) => {
try {
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'reader://reset-password',
});
if (error) throw error;
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error.message : 'Fehler beim Zurücksetzen',
};
}
};
return {
session,
user: session?.user ?? null,
loading,
signUp,
signIn,
signOut,
resetPassword,
};
};

View file

@ -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<Text[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchTexts();
// Realtime Subscription
const subscription = supabase
.channel('texts_changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'texts',
},
(payload) => {
if (payload.eventType === 'INSERT') {
// Check if text already exists to avoid duplicates
setTexts((prev) => {
const exists = prev.some((text) => text.id === payload.new.id);
if (exists) return prev;
return [payload.new as Text, ...prev];
});
} else if (payload.eventType === 'UPDATE') {
setTexts((prev) =>
prev.map((text) => (text.id === payload.new.id ? (payload.new as Text) : text))
);
} else if (payload.eventType === 'DELETE') {
setTexts((prev) => prev.filter((text) => text.id !== payload.old.id));
}
}
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, []);
const fetchTexts = async () => {
try {
setLoading(true);
const { data, error } = await supabase
.from('texts')
.select('*')
.order('updated_at', { ascending: false });
if (error) throw error;
setTexts(data || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setLoading(false);
}
};
const createText = async (title: string, content: string, initialData?: Partial<TextData>) => {
try {
// Get current user
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw new Error('Benutzer nicht eingeloggt');
}
const { data, error } = await supabase
.from('texts')
.insert({
title,
content,
user_id: user.id, // Explicitly set user_id
data: {
tts: { speed: 1.0, voice: 'de-DE-Neural2-A' },
tags: [],
stats: { playCount: 0, totalTime: 0, completed: false },
...initialData,
},
})
.select()
.single();
if (error) throw error;
// Refresh the texts list to ensure we have the latest data
await fetchTexts();
return { data, error: null };
} catch (err) {
return {
data: null,
error: err instanceof Error ? err.message : 'Fehler beim Erstellen',
};
}
};
const updateText = async (textId: string, updates: Partial<Text>) => {
try {
const { data, error } = await supabase
.from('texts')
.update(updates)
.eq('id', textId)
.select()
.single();
if (error) throw error;
return { data, error: null };
} catch (err) {
return {
data: null,
error: err instanceof Error ? err.message : 'Fehler beim Aktualisieren',
};
}
};
const deleteText = async (textId: string) => {
try {
const { error } = await supabase.from('texts').delete().eq('id', textId);
if (error) throw error;
return { error: null };
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Fehler beim Löschen',
};
}
};
const updatePosition = async (textId: string, position: number) => {
const text = texts.find((t) => t.id === textId);
if (!text) return { error: 'Text nicht gefunden' };
return updateText(textId, {
data: {
...text.data,
tts: {
...text.data.tts,
lastPosition: position,
lastPlayed: new Date().toISOString(),
},
},
});
};
const getTextsByTag = (tag: string) => {
return texts.filter((text) => text.data.tags?.includes(tag));
};
const getAllTags = () => {
const tagSet = new Set<string>();
texts.forEach((text) => {
text.data.tags?.forEach((tag) => tagSet.add(tag));
});
return Array.from(tagSet).sort();
};
return {
texts,
loading,
error,
createText,
updateText,
deleteText,
updatePosition,
getTextsByTag,
getAllTags,
refetch: fetchTexts,
};
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<void> {
try {
await FileSystem.makeDirectoryAsync(AUDIO_DIR, { intermediates: true });
} catch {
// Directory might already exist
}
}
// Generate audio for a text using Supabase Edge Function
async generateAudioForText(
textId: string,
content: string,
voice: string = 'de-DE',
speed: number = 1.0,
chunkSize: number = 1000,
onProgress?: (progress: AudioGenerationProgress) => void,
versionId?: string
): Promise<{ success: boolean; error?: string; chunks?: AudioChunk[] }> {
try {
// Estimate number of chunks for progress tracking
const estimatedChunks = Math.ceil(content.length / chunkSize);
onProgress?.({
chunksCompleted: 0,
totalChunks: estimatedChunks,
currentChunk: 'Starting generation...',
isComplete: false,
});
// Determine which provider to use based on the voice
let provider = 'google';
try {
const voiceInfo = getVoiceById(voice);
if (voiceInfo) {
provider = voiceInfo.provider;
} else {
console.warn(`Voice not found: ${voice}, defaulting to Google provider`);
}
} catch (error) {
console.error('Error getting voice info:', error);
// Continue with default Google provider
}
const { data, error } = await supabase.functions.invoke('generate-audio', {
body: {
textId,
content,
voice,
provider,
speed,
chunkSize,
versionId,
},
});
if (error) {
throw error;
}
if (!data.success) {
throw new Error(data.error || 'Failed to generate audio');
}
onProgress?.({
chunksCompleted: data.chunksGenerated,
totalChunks: data.chunksGenerated,
currentChunk: 'Audio generation complete!',
isComplete: true,
});
return {
success: true,
chunks: data.chunks,
};
} catch (error) {
console.error('Error generating audio:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// Download audio chunks to local storage
async downloadAudioChunks(
textId: string,
chunks: AudioChunk[],
onProgress?: (progress: { completed: number; total: number; currentChunk: string }) => void
): Promise<{ success: boolean; error?: string; localChunks?: AudioChunk[] }> {
try {
const localChunks: AudioChunk[] = [];
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
onProgress?.({
completed: i,
total: chunks.length,
currentChunk: chunk.id,
});
// Get signed URL for the chunk
const { data: urlData, error: urlError } = await supabase.functions.invoke(
'get-audio-url',
{
body: {
textId,
chunkId: chunk.id,
},
}
);
if (urlError || !urlData.success) {
throw new Error(`Failed to get URL for chunk ${chunk.id}`);
}
// Download the audio file
const localFilePath = `${AUDIO_DIR}${textId}_${chunk.id}.mp3`;
const downloadResult = await FileSystem.downloadAsync(urlData.url, localFilePath);
if (downloadResult.status !== 200) {
throw new Error(`Failed to download chunk ${chunk.id}`);
}
// Get file info
const fileInfo = await FileSystem.getInfoAsync(localFilePath);
localChunks.push({
...chunk,
filename: `${textId}_${chunk.id}.mp3`,
size: fileInfo.exists && 'size' in fileInfo ? fileInfo.size : chunk.size,
});
}
onProgress?.({
completed: chunks.length,
total: chunks.length,
currentChunk: 'Download complete!',
});
return {
success: true,
localChunks,
};
} catch (error) {
console.error('Error downloading audio chunks:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// Play audio directly from Supabase Storage
async playAudioFromSupabase(
textId: string,
chunks: AudioChunk[],
startPosition: number = 0
): Promise<{ sound?: Audio.Sound; chunk?: AudioChunk; chunks?: AudioChunk[]; error?: string }> {
try {
// Calculate chunk positions if not already set
let currentPosition = 0;
const chunksWithPositions = chunks.map((chunk) => {
const chunkStart = currentPosition;
const chunkEnd = currentPosition + chunk.duration * 1000; // Convert to milliseconds
currentPosition = chunkEnd;
return {
...chunk,
start: chunk.start ?? chunkStart,
end: chunk.end ?? chunkEnd,
};
});
// Find the chunk that contains the start position
const chunk =
chunksWithPositions.find((c) => startPosition >= c.start && startPosition < c.end) ||
chunksWithPositions[0]; // Default to first chunk if position not found
if (!chunk) {
throw new Error('No chunk found for the given position');
}
// Get signed URL for the audio chunk
const { data: urlData, error: urlError } = await this.supabase.functions.invoke(
'get-audio-url',
{
body: {
textId,
chunkId: chunk.id,
},
}
);
if (urlError || !urlData.success) {
throw new Error(`Failed to get audio URL: ${urlError?.message || 'Unknown error'}`);
}
// Create and load the audio from signed URL
const { sound } = await Audio.Sound.createAsync({ uri: urlData.url });
// Calculate position within the chunk
const positionWithinChunk = Math.max(0, startPosition - chunk.start);
await sound.setPositionAsync(positionWithinChunk);
return { sound, chunk, chunks: chunksWithPositions };
} catch (error) {
console.error('Error playing audio from Supabase:', error);
return {
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// Play audio from local cache (kept for backward compatibility)
async playAudioFromCache(
textId: string,
chunks: AudioChunk[],
startPosition: number = 0
): Promise<{ sound?: Audio.Sound; error?: string }> {
try {
// Find the chunk that contains the start position
const chunk = chunks.find((c) => startPosition >= c.start && startPosition < c.end);
if (!chunk) {
throw new Error('No chunk found for the given position');
}
const filePath = `${AUDIO_DIR}${chunk.filename}`;
const fileInfo = await FileSystem.getInfoAsync(filePath);
if (!fileInfo.exists) {
throw new Error('Audio file not found locally');
}
// Create and load the audio
const { sound } = await Audio.Sound.createAsync({ uri: filePath });
// Calculate position within the chunk
const chunkProgress = (startPosition - chunk.start) / (chunk.end - chunk.start);
const positionMillis = chunkProgress * chunk.duration * 1000;
await sound.setPositionAsync(positionMillis);
return { sound };
} catch (error) {
console.error('Error playing audio from cache:', error);
return {
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
// Clear local audio cache for a text
async clearAudioCache(textId: string, chunks: AudioChunk[]): Promise<void> {
try {
for (const chunk of chunks) {
const filePath = `${AUDIO_DIR}${chunk.filename}`;
try {
await FileSystem.deleteAsync(filePath);
} catch (deleteError) {
console.log(`Could not delete ${chunk.filename}:`, deleteError);
}
}
} catch (error) {
console.error('Error clearing audio cache:', error);
}
}
// Get total cache size
async getCacheSize(): Promise<number> {
try {
const files = await FileSystem.readDirectoryAsync(AUDIO_DIR);
let totalSize = 0;
for (const file of files) {
const fileInfo = await FileSystem.getInfoAsync(`${AUDIO_DIR}${file}`);
totalSize += fileInfo.exists && 'size' in fileInfo ? fileInfo.size : 0;
}
return totalSize;
} catch (error) {
console.error('Error calculating cache size:', error);
return 0;
}
}
// Check if audio is cached locally
async isAudioCached(textId: string, chunks: AudioChunk[]): Promise<boolean> {
try {
for (const chunk of chunks) {
const filePath = `${AUDIO_DIR}${chunk.filename}`;
const fileInfo = await FileSystem.getInfoAsync(filePath);
if (!fileInfo.exists) {
return false;
}
}
return true;
} catch {
return false;
}
}
// Get file path for a chunk
getChunkFilePath(textId: string, chunkId: string): string {
return `${AUDIO_DIR}${textId}_${chunkId}.mp3`;
}
}

View file

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

View file

@ -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<AppState['settings']>) => void;
// Audio Player
currentTextId: string | null;
isPlaying: boolean;
currentPosition: number;
setCurrentText: (textId: string | null) => void;
setIsPlaying: (playing: boolean) => void;
setCurrentPosition: (position: number) => void;
// UI State
selectedTags: string[];
toggleTag: (tag: string) => void;
clearTags: () => void;
}
export const useStore = create<AppState>()(
persist(
(set) => ({
// User
user: null,
setUser: (user) => set({ user }),
// Settings
settings: {
voice: 'de-DE-Neural2-A',
speed: 1.0,
theme: 'light',
playbackRate: 1.0,
},
updateSettings: (newSettings) =>
set((state) => ({
settings: { ...state.settings, ...newSettings },
})),
// Audio Player
currentTextId: null,
isPlaying: false,
currentPosition: 0,
setCurrentText: (textId) => set({ currentTextId: textId, currentPosition: 0 }),
setIsPlaying: (playing) => set({ isPlaying: playing }),
setCurrentPosition: (position) => set({ currentPosition: position }),
// UI State
selectedTags: [],
toggleTag: (tag) =>
set((state) => ({
selectedTags: state.selectedTags.includes(tag)
? state.selectedTags.filter((t) => t !== tag)
: [...state.selectedTags, tag],
})),
clearTags: () => set({ selectedTags: [] }),
}),
{
name: 'reader-storage',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
settings: state.settings,
selectedTags: state.selectedTags,
}),
}
)
);

Some files were not shown because too many files have changed in this diff Show more