mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
e5a6946d8b
commit
348b6ff231
126 changed files with 13 additions and 11273 deletions
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
node_modules
|
||||
.svelte-kit
|
||||
.git
|
||||
*.md
|
||||
.env*
|
||||
!.env.example
|
||||
dist
|
||||
coverage
|
||||
.DS_Store
|
||||
7
apps/playground/apps/web/.gitignore
vendored
7
apps/playground/apps/web/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
node_modules/
|
||||
.svelte-kit/
|
||||
.turbo/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
18
apps/playground/apps/web/src/app.d.ts
vendored
18
apps/playground/apps/web/src/app.d.ts
vendored
|
|
@ -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 {};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* Auth Store — uses centralized Mana auth factory.
|
||||
*/
|
||||
|
||||
import { createManaAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createManaAuthStore();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()],
|
||||
});
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"name": "playground",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
40
apps/reader/.gitignore
vendored
40
apps/reader/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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';
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
2
apps/reader/apps/mobile/app-env.d.ts
vendored
2
apps/reader/apps/mobile/app-env.d.ts
vendored
|
|
@ -1,2 +0,0 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}`;
|
||||
|
|
@ -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]`,
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -1,10 +0,0 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -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`,
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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`,
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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' });
|
||||
3
apps/reader/apps/mobile/nativewind-env.d.ts
vendored
3
apps/reader/apps/mobile/nativewind-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
};
|
||||
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue