feat(chat): integrate chat project into monorepo with full app structure

- Restructure chat as apps/mobile, apps/web, apps/landing, backend
- Add NestJS backend for secure Azure OpenAI API calls
- Remove exposed API key from mobile app (security fix)
- Add shared chat-types package
- Create SvelteKit web app scaffold
- Create Astro landing page scaffold
- Update pnpm workspace configuration
- Add project-level CLAUDE.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 13:48:24 +01:00
parent fcf3a344b1
commit c638a7ffee
155 changed files with 22622 additions and 348 deletions

122
chat/CLAUDE.md Normal file
View file

@ -0,0 +1,122 @@
# Chat Project Guide
## Project Structure
```
chat/
├── apps/
│ ├── landing/ # Astro marketing landing page (@chat/landing)
│ ├── web/ # SvelteKit web application (@chat/web)
│ └── mobile/ # Expo/React Native mobile app (@chat/mobile)
├── backend/ # NestJS API server (@chat/backend)
├── packages/
│ └── chat-types/ # Shared TypeScript types (@chat/types)
└── package.json
```
## Commands
### Root Level
```bash
pnpm chat:dev # Run all chat apps
pnpm dev:chat:mobile # Start mobile app
pnpm dev:chat:web # Start web app
pnpm dev:chat:landing # Start landing page
pnpm dev:chat:backend # Start backend server
```
### Mobile App (chat/apps/mobile)
```bash
pnpm dev # Start Expo dev server
pnpm ios # Run on iOS simulator
pnpm android # Run on Android emulator
pnpm build:dev # Build development version
pnpm build:preview # Build preview version
pnpm build:prod # Build production version
```
### Backend (chat/backend)
```bash
pnpm start:dev # Start with hot reload
pnpm build # Build for production
pnpm start:prod # Start production server
```
### Web App (chat/apps/web)
```bash
pnpm dev # Start dev server
pnpm build # Build for production
pnpm preview # Preview production build
```
### Landing Page (chat/apps/landing)
```bash
pnpm dev # Start dev server
pnpm build # Build for production
pnpm preview # Preview production build
```
## Technology Stack
- **Mobile**: React Native 0.76.7 + Expo SDK 52, NativeWind, Expo Router
- **Web**: SvelteKit 2.x, Svelte 5, Tailwind CSS 4
- **Landing**: Astro 5.16, Tailwind CSS
- **Backend**: NestJS 10, Azure OpenAI, Supabase
- **Types**: TypeScript 5.x
## Architecture
### Backend API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/health` | GET | Health check |
| `/api/chat/models` | GET | List available AI models |
| `/api/chat/completions` | POST | Create chat completion |
| `/api/conversations` | GET | List user conversations |
| `/api/conversations/:id` | GET | Get conversation details |
| `/api/conversations/:id/messages` | GET | Get conversation messages |
| `/api/conversations` | POST | Create new conversation |
| `/api/conversations/:id/messages` | POST | Add message to conversation |
### Environment Variables
#### Backend (.env)
```
AZURE_OPENAI_ENDPOINT=https://...
AZURE_OPENAI_API_KEY=...
AZURE_OPENAI_API_VERSION=2024-12-01-preview
SUPABASE_URL=https://...
SUPABASE_SERVICE_KEY=...
PORT=3001
```
#### Mobile (.env)
```
EXPO_PUBLIC_SUPABASE_URL=https://...
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
EXPO_PUBLIC_BACKEND_URL=http://localhost:3001
```
## Code Style Guidelines
- **TypeScript**: Strict typing with interfaces
- **Mobile**: Functional components with hooks
- **Web**: Svelte 5 runes mode
- **Styling**: Tailwind CSS everywhere
- **Formatting**: 100 char line limit, 2 space tabs, single quotes
## AI Models Available
| Model ID | Name | Description |
|----------|------|-------------|
| 550e8400-e29b-41d4-a716-446655440000 | GPT-O3-Mini | Fast, efficient responses |
| 550e8400-e29b-41d4-a716-446655440004 | GPT-4o-Mini | Compact, powerful |
| 550e8400-e29b-41d4-a716-446655440005 | GPT-4o | Most advanced |
## Important Notes
1. **Security**: API keys are stored in the backend only - never in client apps
2. **Authentication**: Uses Supabase Auth, shared with Mana Core ecosystem
3. **Database**: Supabase PostgreSQL with RLS policies
4. **Deployment**: Backend runs on port 3001 by default

View file

@ -0,0 +1,11 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://chat.manacore.app',
integrations: [
tailwind(),
sitemap()
]
});

View file

@ -0,0 +1,26 @@
{
"name": "@chat/landing",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@astrojs/sitemap": "^3.2.1",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.16.0",
"typescript": "^5.0.0"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.0",
"@tailwindcss/typography": "^0.5.16",
"tailwindcss": "^3.4.17"
}
}

View file

@ -0,0 +1,28 @@
---
interface Props {
title: string;
description?: string;
}
const { title, description = 'ManaChat - AI Chat Assistant' } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="bg-white dark:bg-gray-900">
<slot />
</body>
</html>
<style is:global>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>

View file

@ -0,0 +1,100 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="ManaChat - AI Chat Assistant">
<main class="min-h-screen">
<!-- Hero Section -->
<section class="relative overflow-hidden bg-gradient-to-br from-blue-600 to-purple-700 py-20 text-white">
<div class="container mx-auto px-4 text-center">
<h1 class="mb-6 text-5xl font-bold md:text-6xl">
ManaChat
</h1>
<p class="mb-8 text-xl text-blue-100 md:text-2xl">
Dein intelligenter KI-Chat-Assistent
</p>
<p class="mx-auto mb-12 max-w-2xl text-lg text-blue-200">
Chatte mit den leistungsstärksten KI-Modellen. GPT-4o, GPT-4o-Mini und mehr -
alles in einer einfachen, eleganten Oberfläche.
</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="#features"
class="rounded-full bg-white px-8 py-3 font-semibold text-blue-600 transition hover:bg-blue-50"
>
Mehr erfahren
</a>
<a
href="https://apps.apple.com"
class="rounded-full border-2 border-white px-8 py-3 font-semibold text-white transition hover:bg-white hover:text-blue-600"
>
App herunterladen
</a>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-20">
<div class="container mx-auto px-4">
<h2 class="mb-12 text-center text-3xl font-bold text-gray-900 dark:text-white">
Funktionen
</h2>
<div class="grid gap-8 md:grid-cols-3">
<div class="rounded-xl bg-gray-50 p-6 dark:bg-gray-800">
<div class="mb-4 text-4xl">🤖</div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">
Mehrere KI-Modelle
</h3>
<p class="text-gray-600 dark:text-gray-300">
Wähle zwischen GPT-4o, GPT-4o-Mini und weiteren Modellen für deine Gespräche.
</p>
</div>
<div class="rounded-xl bg-gray-50 p-6 dark:bg-gray-800">
<div class="mb-4 text-4xl">💬</div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">
Konversationen speichern
</h3>
<p class="text-gray-600 dark:text-gray-300">
Alle deine Chats werden sicher gespeichert und sind jederzeit abrufbar.
</p>
</div>
<div class="rounded-xl bg-gray-50 p-6 dark:bg-gray-800">
<div class="mb-4 text-4xl">📱</div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">
Plattformübergreifend
</h3>
<p class="text-gray-600 dark:text-gray-300">
Nutze ManaChat auf iOS, Android und im Web - deine Daten sind überall synchronisiert.
</p>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="bg-gray-100 py-20 dark:bg-gray-800">
<div class="container mx-auto px-4 text-center">
<h2 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white">
Bereit für intelligente Gespräche?
</h2>
<p class="mb-8 text-lg text-gray-600 dark:text-gray-300">
Starte jetzt kostenlos mit ManaChat.
</p>
<a
href="https://apps.apple.com"
class="inline-block rounded-full bg-blue-600 px-8 py-3 font-semibold text-white transition hover:bg-blue-700"
>
Jetzt herunterladen
</a>
</div>
</section>
<!-- Footer -->
<footer class="bg-gray-900 py-8 text-gray-400">
<div class="container mx-auto px-4 text-center">
<p>&copy; 2024 ManaChat. Powered by Mana Core.</p>
</div>
</footer>
</main>
</BaseLayout>

View file

@ -0,0 +1,28 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#0A84FF',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
secondary: {
500: '#5E5CE6',
}
}
},
},
plugins: [
require('@tailwindcss/typography'),
],
};

View file

@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,7 @@
# Supabase Konfiguration
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
# Chat Backend API
# The backend handles AI API calls securely - no API keys needed in the mobile app
EXPO_PUBLIC_BACKEND_URL=http://localhost:3001

25
chat/apps/mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# expo router
expo-env.d.ts
# firebase/supabase/vexo
.env
ios
android
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

View file

@ -0,0 +1,52 @@
# Claude's Guide to Chat Mobile App
## Commands
- Start app: `pnpm dev` or `pnpm start`
- iOS: `pnpm ios`
- Android: `pnpm android`
- Lint: `pnpm lint`
- Format: `pnpm format`
- Build: `pnpm build:dev`, `pnpm build:preview`, `pnpm build:prod`
- Supabase: `pnpm supabase:cli`, `pnpm supabase:update-models`, `pnpm supabase:setup`
## Architecture
### Backend Integration
- **AI API calls go through the backend** - NOT directly from the mobile app
- Backend URL configured via `EXPO_PUBLIC_BACKEND_URL` environment variable
- API keys are stored securely in the backend only
- `utils/backendApi.ts` - Backend client for AI completions
- `utils/api.ts` - API wrapper that routes calls to backend
### Key Files
- `config/azure.ts` - Model definitions (NO API keys!)
- `services/openai.ts` - Chat service using backend
- `utils/backendApi.ts` - Backend API client
- `utils/supabase.ts` - Supabase client for data persistence
## Code Style Guidelines
- **TypeScript**: Strict typing with interfaces for props and state
- **Components**: Functional components with hooks, located in `/components`
- **Navigation**: Expo Router in `/app` directory
- **Styling**: NativeWind (Tailwind CSS for React Native)
- **Imports**: Path aliases with `~/*` for project root
- **Formatting**: 100 char line limit, 2 space tabs, single quotes
- **State**: React Context API for global state
- **Backend**: Uses NestJS backend for AI calls, Supabase for data
- **Naming**: PascalCase for components, camelCase for functions/variables
- **Error Handling**: Try/catch with contextual error messages
## Environment Variables
```
EXPO_PUBLIC_SUPABASE_URL=https://...
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
EXPO_PUBLIC_BACKEND_URL=http://localhost:3001
```
## Running with Backend
1. Start the backend first: `pnpm dev:chat:backend`
2. Then start the mobile app: `pnpm dev:chat:mobile`
The mobile app will connect to the backend for AI completions.

View file

@ -0,0 +1,63 @@
# Chat App
Eine moderne mobile Chat-Anwendung zur Interaktion mit verschiedenen KI-Sprachmodellen.
## Funktionen
- 💬 Chat mit verschiedenen KI-Modellen (GPT-4, GPT-3.5, Claude 3)
- 🔄 Verschiedene Konversationsmodi (frei, geführt, vorlagenbasiert)
- 👤 Benutzerauthentifizierung (Registrierung, Anmeldung, Passwort-Reset)
- 📱 Cross-Platform (iOS, Android, Web) mit Expo
- 🎨 Modernes UI mit NativeWind (Tailwind CSS)
## Technologie-Stack
- **Frontend:** React Native mit Expo SDK 52
- **Routing:** Expo Router v4
- **Styling:** NativeWind (Tailwind CSS)
- **Backend:** Supabase (Auth, PostgreSQL)
- **API:** Azure OpenAI API
## Einrichtung
1. Repository klonen
```
git clone <repository-url>
cd chat
```
2. Abhängigkeiten installieren
```
npm install
```
3. Umgebungsvariablen konfigurieren
```
cp .env.example .env
```
Dann `.env` mit deinen Supabase- und Azure OpenAI-Zugangsdaten bearbeiten.
4. Entwicklungsserver starten
```
npm run start
```
## Projektstruktur
- `/app` - Hauptanwendungslogik (Expo Router)
- `/components` - Wiederverwendbare UI-Komponenten
- `/services` - Business-Logik und API-Dienste
- `/utils` - Hilfsfunktionen
- `/context` - React Context Provider
## Nutzung
Nach dem Start kannst du:
- Dich registrieren oder anmelden
- Ein KI-Modell auswählen
- Eine neue Konversation starten
- Zwischen verschiedenen Konversationsmodi wechseln
## Lizenz
MIT

View file

@ -0,0 +1,55 @@
# Vereinfachungsplan für Chat App
Basierend auf der Codeanalyse schlage ich folgende Maßnahmen zur Vereinfachung des Projekts vor:
## 1. Komponenten-Konsolidierung
- **Chat-Eingabefelder**: `MessageInput.tsx` und `ChatPromptInput.tsx` zu einer Komponente zusammenführen
- **Modell-Auswahl**: Die Logik aus `ModelDropdown.tsx` und `model-selection.tsx` in einen gemeinsamen Service extrahieren
- **Nachrichten-Darstellung**: Eine wiederverwendbare `MessageRenderer`-Komponente für alle Nachrichten-Displaytypen erstellen
## 2. Code-Reduktion
- **Redundante Modell-Definitionen**: Gemeinsame Typendefinitionen in `types/index.ts` zentralisieren
- **API-Wrapper**: XHR durch einfachen Fetch-API-Wrapper in `utils/api.ts` ersetzen
- **Error Handling**: Zentrales Fehlerbehandlungssystem statt wiederholter try/catch-Blöcke
- **Styling**: Vollständig auf NativeWind umstellen und StyleSheet.create entfernen
## 3. Architektur-Optimierung
- **State Management**:
- Auth-Zustand über einen zentralen Store verwalten
- Modell- und Konversationszustand aus UI-Komponenten in Services verlagern
- **Typ-System**:
- Gemeinsame Schnittstellen für Modelle, Nachrichten und Konversationen
- Striktere Typprüfung für alle API-Antworten
- **Service-Layer**:
- Klare Trennung zwischen UI, Datenmodell und API-Logik
- Einheitliche Fehlerrückgabe mit Typisierung
## 4. Dateistruktur
```
/app - Screens & Routing
/components - UI-Komponenten
/hooks - Gemeinsame React Hooks
/services - Business-Logik
/types - Typendefinitionen
/utils - Hilfsfunktionen
```
## 5. Performance-Optimierungen
- Virtualisierte Listen für große Nachrichtenthreads
- Optimistische UI-Updates für bessere UX
- Caching von Modellantworten zur Reduzierung von API-Aufrufen
## Implementierungsreihenfolge
1. Typensystem konsolidieren
2. API-Wrapper erstellen
3. State Management umstellen
4. UI-Komponenten vereinheitlichen
5. Styling standardisieren

View file

@ -0,0 +1,38 @@
# Vereinfachungsplan: Status
Fortschritt bei der Umsetzung des Vereinfachungsplans:
## ✅ Zentrale Typendefinitionen
- Typendefinitionen für Message, Model, Conversation, etc. in `/types/index.ts` erstellt
- Stellt sicher, dass alle Komponenten die gleichen Typen verwenden
## ✅ API-Wrapper
- Modern `fetch`-basierter API-Wrapper in `/utils/api.ts` erstellt
- Ersetzt ältere XHR-Implementierung
- Implementiert Timeout-Handling, Fehlerbehandlung und Typsicherheit
## ✅ Fehlerbehandlung
- Zentrale Fehlerbehandlung in `/utils/error.ts` erstellt
- Unterstützt verschiedene Fehlertypen (API, Netzwerk, Validierung, etc.)
- Bietet einheitliche Fehleranzeige und -protokollierung
## ✅ UI-Komponenten
- `useChatInput`-Hook für Eingabefelder erstellt
- `ChatInput`-Komponente vereinheitlicht die verschiedenen Nachrichteneingabefelder
- `MessageRenderer`-Komponente für einheitliche Nachrichtenanzeige erstellt
## ✅ Services
- `modelService.ts` zentralisiert die Modell-Logik
- Implementiert Caching, Fallback-Modelle und Validierung
## ⏳ Noch ausstehend
- Umstellung redundanter Modell-Code auf den neuen `modelService`
- Konsolidierung der Konversationslogik
- Standardisierung aller Komponenten auf NativeWind
- Erstellen weiterer gemeinsamer React Hooks
## Verbesserungen
1. **Einfachere Codeorganisation**: zentrale Typen, weniger doppelter Code
2. **Verbesserte Fehlerbehandlung**: konsistente Fehlermeldungen
3. **Reduzierte Redundanz**: vereinheitlichte UI-Komponenten
4. **Bessere Wartbarkeit**: klare Trennung zwischen Datenzugriff und UI

2
chat/apps/mobile/app-env.d.ts vendored Normal file
View file

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

56
chat/apps/mobile/app.json Normal file
View file

@ -0,0 +1,56 @@
{
"expo": {
"name": "chat",
"slug": "chat",
"version": "1.0.0",
"scheme": "chat",
"web": {
"bundler": "metro",
"output": "server",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-dev-launcher",
{
"launchMode": "most-recent"
}
]
],
"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.chat"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.tilljs.chat"
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "67f22a8b-3cae-487d-af1f-55bdaca50e81"
}
}
}
}

View file

@ -0,0 +1,79 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Drawer } from 'expo-router/drawer';
import { Ionicons } from '@expo/vector-icons';
import { useAppTheme } from '../../theme/ThemeProvider';
export default function DrawerLayout() {
const { isDarkMode } = useAppTheme();
// Anpassen des Drawer-Stils basierend auf dem Farbschema
const drawerStyles = {
backgroundColor: isDarkMode ? '#1C1C1E' : '#FFFFFF',
contentOptions: {
activeTintColor: '#0A84FF',
inactiveTintColor: isDarkMode ? '#FFFFFF' : '#000000',
activeBackgroundColor: isDarkMode ? '#2C2C2E' : '#E5E5EA',
},
};
return (
<Drawer
screenOptions={{
headerShown: false,
drawerStyle: {
backgroundColor: drawerStyles.backgroundColor,
},
drawerActiveTintColor: drawerStyles.contentOptions.activeTintColor,
drawerInactiveTintColor: drawerStyles.contentOptions.inactiveTintColor,
drawerActiveBackgroundColor: drawerStyles.contentOptions.activeBackgroundColor,
}}
>
<Drawer.Screen
name="index"
options={{
title: 'Chat',
drawerIcon: ({ color, size }) => (
<Ionicons name="chatbubbles-outline" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="documents"
options={{
title: 'Dokumente',
drawerIcon: ({ color, size }) => (
<Ionicons name="document-text-outline" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="archive"
options={{
title: 'Archiv',
drawerIcon: ({ color, size }) => (
<Ionicons name="archive-outline" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="templates"
options={{
title: 'Vorlagen',
drawerIcon: ({ color, size }) => (
<Ionicons name="file-tray-full-outline" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="profile"
options={{
title: 'Profil',
drawerIcon: ({ color, size }) => (
<Ionicons name="person-outline" size={size} color={color} />
),
}}
/>
</Drawer>
);
}

View file

@ -0,0 +1,46 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
{/*
This viewport disables scaling which makes the mobile website act more like a native app.
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
*/}
<meta
name="viewport"
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
/>
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

View file

@ -0,0 +1,24 @@
import { Link, Stack } from 'expo-router';
import { Text } from 'react-native';
import { Container } from '~/components/Container';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<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>
</Container>
</>
);
}
const styles = {
title: `text-xl font-bold`,
link: `mt-4 pt-4`,
linkText: `text-base text-[#2e78b7]`,
};

View file

@ -0,0 +1,72 @@
import '../global.css';
import { Stack, useRouter, useSegments } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider as NavigationThemeProvider } from '@react-navigation/native';
import { useAppTheme } from '../theme/ThemeProvider';
import { ThemeProvider } from '../theme/ThemeProvider';
import { AuthProvider, useAuth } from '../context/AuthProvider';
import { useEffect } from 'react';
export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(drawer)',
};
function Layout() {
const { theme } = useAppTheme();
return (
<NavigationThemeProvider value={theme}>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ title: 'Modal', presentation: 'modal' }} />
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="model-selection" options={{ headerShown: false }} />
<Stack.Screen name="templates" options={{ headerShown: false }} />
<Stack.Screen name="conversation/[id]" options={{ headerShown: false }} />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="auth/register" options={{ headerShown: false }} />
<Stack.Screen name="auth/reset-password" options={{ headerShown: false }} />
<Stack.Screen name="profile" options={{ headerShown: false }} />
</Stack>
</GestureHandlerRootView>
</NavigationThemeProvider>
);
}
// Authentifizierungsprüfung und Umleitung
function AuthGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (loading) return;
const inAuthGroup = segments[0] === 'auth';
if (!user && !inAuthGroup) {
// Wenn kein Benutzer angemeldet ist und nicht auf einer Auth-Seite, zur Login-Seite umleiten
router.replace('/auth/login');
} else if (user && inAuthGroup) {
// Wenn ein Benutzer angemeldet ist und auf einer Auth-Seite, zur Hauptseite umleiten
router.replace('/');
}
}, [user, loading, segments]);
return <>{children}</>;
}
export default function RootLayout() {
return (
<ThemeProvider>
<AuthProvider>
<AuthGuard>
<Layout />
</AuthGuard>
</AuthProvider>
</ThemeProvider>
);
}

View file

@ -0,0 +1,148 @@
import { supabase } from '../../utils/supabase';
// Definiere den Typ für ein Modell
export type Model = {
id: string;
name: string;
description: string;
parameters?: Record<string, any>;
created_at?: string;
updated_at?: string;
};
// Fallback-Modelle, falls keine aus der Datenbank geladen werden können
const FALLBACK_MODELS: Model[] = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'GPT-O3-Mini',
description: 'Azure OpenAI O3-Mini: Effizientes Modell für schnelle Antworten.',
parameters: {
temperature: 0.7,
max_tokens: 800,
provider: 'azure',
deployment: 'gpt-o3-mini-se',
endpoint: 'https://memoroseopenai.openai.azure.com',
api_version: '2024-12-01-preview'
}
},
{
id: '550e8400-e29b-41d4-a716-446655440004',
name: 'GPT-4o-Mini',
description: 'Azure OpenAI GPT-4o-Mini: Kompaktes, leistungsstarkes KI-Modell.',
parameters: {
temperature: 0.7,
max_tokens: 1000,
provider: 'azure',
deployment: 'gpt-4o-mini-se',
endpoint: 'https://memoroseopenai.openai.azure.com',
api_version: '2024-12-01-preview'
}
},
{
id: '550e8400-e29b-41d4-a716-446655440005',
name: 'GPT-4o',
description: 'Azure OpenAI GPT-4o: Das fortschrittlichste multimodale KI-Modell.',
parameters: {
temperature: 0.7,
max_tokens: 1200,
provider: 'azure',
deployment: 'gpt-4o-se',
endpoint: 'https://memoroseopenai.openai.azure.com',
api_version: '2024-12-01-preview'
}
}
];
// GET-Handler für Modelle
export async function GET(request: Request) {
try {
// Versuche, Modelle aus der Supabase-Datenbank zu laden
let models: Model[] = FALLBACK_MODELS;
// Wenn Supabase konfiguriert ist, versuche die Modelle von dort zu laden
try {
if (supabase) {
const { data, error } = await supabase
.from('models')
.select('*');
// Entfernt: .order('created_at', { ascending: false })
if (error) {
console.error('Fehler beim Laden der Modelle aus Supabase:', error);
} else if (data && data.length > 0) {
models = data as Model[];
}
}
} catch (e) {
console.error('Fehler bei der Supabase-Verbindung:', e);
// Fallback zu den vordefinierten Modellen
}
return Response.json(models);
} catch (error) {
console.error('Fehler beim Verarbeiten der Anfrage:', error);
return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
// POST-Handler zum Erstellen eines neuen Modells
export async function POST(request: Request) {
try {
const body = await request.json();
// Validiere die Eingabedaten
if (!body.name || !body.description) {
return new Response(JSON.stringify({ error: 'Name und Beschreibung sind erforderlich' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
// Erstelle ein neues Modell in der Datenbank
if (supabase) {
const { data, error } = await supabase
.from('models')
.insert([{
name: body.name,
description: body.description,
parameters: body.parameters || {},
}])
.select();
if (error) {
console.error('Fehler beim Erstellen des Modells:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Erstellen des Modells' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
return Response.json(data[0]);
} else {
// Wenn Supabase nicht verfügbar ist, gib einen Fehler zurück
return new Response(JSON.stringify({ error: 'Datenbank nicht verfügbar' }), {
status: 503,
headers: {
'Content-Type': 'application/json',
},
});
}
} catch (error) {
console.error('Fehler beim Verarbeiten der Anfrage:', error);
return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}

View file

@ -0,0 +1,137 @@
import { supabase } from '../../utils/supabase';
// Typ für die Token-Nutzung pro Modell
export type ModelUsage = {
model_id: string;
model_name: string;
total_prompt_tokens: number;
total_completion_tokens: number;
total_tokens: number;
total_cost: number;
};
// Typ für die Token-Nutzung nach Zeitraum
export type UsageByPeriod = {
time_period: string;
total_tokens: number;
total_cost: number;
};
// Typ für die Token-Nutzung einer Konversation
export type ConversationUsage = {
message_id: string;
created_at: string;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
estimated_cost: number;
};
// Handler für GET /api/usage
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const userId = url.searchParams.get('userId');
const period = url.searchParams.get('period') || 'month';
if (!userId) {
return new Response(JSON.stringify({ error: 'User ID ist erforderlich' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Lade die Tokennutzung nach Modell
const { data: modelUsage, error: modelError } = await supabase
.rpc('get_user_model_usage', { user_id: userId });
if (modelError) {
console.error('Fehler beim Laden der Modellnutzung:', modelError);
return new Response(JSON.stringify({ error: 'Fehler beim Laden der Modellnutzung' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
// Lade die Tokennutzung nach Zeitraum
const { data: periodUsage, error: periodError } = await supabase
.rpc('get_user_usage_by_period', {
user_id: userId,
period: period
});
if (periodError) {
console.error('Fehler beim Laden der Zeitraumnutzung:', periodError);
return new Response(JSON.stringify({ error: 'Fehler beim Laden der Zeitraumnutzung' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
// Berechne Gesamtkosten und Token
const totalCost = (modelUsage as ModelUsage[]).reduce((sum, model) => sum + model.total_cost, 0);
const totalTokens = (modelUsage as ModelUsage[]).reduce((sum, model) => sum + model.total_tokens, 0);
return Response.json({
modelUsage,
periodUsage,
summary: {
totalCost,
totalTokens
}
});
} catch (error) {
console.error('Fehler beim Verarbeiten der Anfrage:', error);
return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
// Handler für GET /api/usage/conversation
export async function GET_conversation(request: Request) {
try {
const url = new URL(request.url);
const conversationId = url.searchParams.get('conversationId');
if (!conversationId) {
return new Response(JSON.stringify({ error: 'Conversation ID ist erforderlich' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Lade die Tokennutzung für die Konversation
const { data: conversationUsage, error } = await supabase
.rpc('get_conversation_usage', { conversation_id: conversationId });
if (error) {
console.error('Fehler beim Laden der Konversationsnutzung:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Laden der Konversationsnutzung' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
// Berechne Gesamtkosten und Token für diese Konversation
const usage = conversationUsage as ConversationUsage[];
const totalCost = usage.reduce((sum, item) => sum + item.estimated_cost, 0);
const totalTokens = usage.reduce((sum, item) => sum + item.total_tokens, 0);
return Response.json({
conversationUsage,
summary: {
totalCost,
totalTokens,
messageCount: usage.length
}
});
} catch (error) {
console.error('Fehler beim Verarbeiten der Anfrage:', error);
return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}

View file

@ -0,0 +1,507 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
SafeAreaView,
Alert,
ActivityIndicator
} from 'react-native';
import { useTheme, useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../context/AuthProvider';
import { useAppTheme } from '../theme/ThemeProvider';
import CustomDrawer from '../components/CustomDrawer';
import {
getArchivedConversations,
getMessages,
deleteConversation,
unarchiveConversation
} from '../services/conversation';
import { supabase } from '../utils/supabase';
// Typendefinitionen für Konversationen
type ConversationItem = {
id: string;
modelName: string;
title: string;
lastMessage: string;
timestamp: Date;
mode: 'frei' | 'geführt' | 'vorlage';
};
// Hilfsfunktion zur Formatierung des Datums
const formatDate = (date: Date) => {
const day = date.getDate().toString().padStart(2, '0');
const month = new Intl.DateTimeFormat('de-DE', { month: 'short' }).format(date);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}. ${month}, ${hours}:${minutes}`;
};
export default function ArchiveScreen() {
const { colors } = useTheme();
const router = useRouter();
const { user } = useAuth();
const [conversations, setConversations] = useState<ConversationItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { isDarkMode } = useAppTheme();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
// Eine Funktion, die Konversationen lädt und wiederverwendet werden kann
const loadConversations = async () => {
if (!user) return;
setIsLoading(true);
try {
console.log("Lade archivierte Konversationen für User:", user.id);
// Lade alle archivierten Konversationen des Benutzers
const userConversations = await getArchivedConversations(user.id);
console.log(`${userConversations.length} archivierte Konversationen geladen`, new Date().toLocaleTimeString());
// Lade für jede Konversation die letzte Nachricht und das Modell
const conversationItems: ConversationItem[] = [];
for (const conv of userConversations) {
try {
// Lade die Nachrichten der Konversation
const messages = await getMessages(conv.id);
// Lade das Modell aus der Datenbank
const { data: modelData } = await supabase
.from('models')
.select('name')
.eq('id', conv.model_id)
.single();
// Finde die letzte Nachricht (die nicht vom System ist)
const lastMessage = messages
.filter(msg => msg.sender !== 'system')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
if (lastMessage) {
conversationItems.push({
id: conv.id,
modelName: modelData?.name || 'Unbekanntes Modell',
title: conv.title || 'Unbenannte Konversation',
lastMessage: lastMessage.message_text,
timestamp: new Date(conv.updated_at),
mode: conv.conversation_mode === 'free' ? 'frei' :
conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage'
});
}
} catch (error) {
console.error(`Fehler beim Laden der Details für Konversation ${conv.id}:`, error);
}
}
setConversations(conversationItems);
} catch (error) {
console.error('Fehler beim Laden der Konversationen:', error);
Alert.alert('Fehler', 'Die Konversationen konnten nicht geladen werden.');
} finally {
setIsLoading(false);
}
};
// Lade die Konversationen beim ersten Rendern und wenn sich der User ändert
useEffect(() => {
loadConversations();
}, [user]);
// Lade Konversationen erneut, wenn der Screen fokussiert wird
useFocusEffect(
useCallback(() => {
if (user) loadConversations();
return () => {};
}, [user])
);
const handleConversationPress = (id: string) => {
// Navigiere zum Konversations-Screen mit der ID
router.push(`/conversation/${id}`);
};
// Löschen einer Konversation
const handleDeleteConversation = (id: string) => {
Alert.alert(
"Konversation löschen",
"Möchtest du diese Konversation wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
[
{
text: "Abbrechen",
style: "cancel"
},
{
text: "Löschen",
style: "destructive",
onPress: async () => {
try {
const success = await deleteConversation(id);
if (success) {
// Aus der lokalen Liste entfernen
setConversations(prev => prev.filter(conv => conv.id !== id));
Alert.alert("Erfolg", "Die Konversation wurde gelöscht.");
} else {
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
}
} catch (error) {
console.error('Fehler beim Löschen der Konversation:', error);
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
}
}
}
]
);
};
// Wiederherstellen einer archivierten Konversation
const handleUnarchiveConversation = async (id: string) => {
try {
const success = await unarchiveConversation(id);
if (success) {
// Aus der lokalen Liste entfernen
setConversations(prev => prev.filter(conv => conv.id !== id));
Alert.alert("Erfolg", "Die Konversation wurde wiederhergestellt.");
} else {
Alert.alert("Fehler", "Die Konversation konnte nicht wiederhergestellt werden.");
}
} catch (error) {
console.error('Fehler beim Wiederherstellen der Konversation:', error);
Alert.alert("Fehler", "Die Konversation konnte nicht wiederhergestellt werden.");
}
};
// Zustandsverwaltung für die Optionsmenüs der Konversationselemente
const [expandedConversationId, setExpandedConversationId] = useState<string | null>(null);
// Toggle-Funktion für das Optionsmenü
const toggleOptionsMenu = (id: string) => {
setExpandedConversationId(expandedConversationId === id ? null : id);
};
const renderConversationItem = ({ item }: { item: ConversationItem }) => {
const showOptions = expandedConversationId === item.id;
return (
<View style={[styles.conversationItemWrapper, { backgroundColor: colors.card }]}>
<TouchableOpacity
style={styles.conversationItem}
onPress={() => handleConversationPress(item.id)}
onLongPress={() => toggleOptionsMenu(item.id)}
>
<View style={styles.conversationContent}>
<View style={styles.conversationHeader}>
<View style={styles.titleRow}>
<Ionicons
name="archive-outline"
size={18}
color={colors.text}
style={styles.titleIcon}
/>
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
{item.title}
</Text>
</View>
<Text style={[styles.timestamp, { color: colors.text + '80' }]}>
{formatDate(item.timestamp)}
</Text>
</View>
<View style={styles.modelContainer}>
<Text style={[styles.modelName, { color: colors.text + 'AA' }]}>
{item.modelName}
</Text>
</View>
<Text
style={[styles.lastMessage, { color: colors.text + 'CC' }]}
numberOfLines={1}
>
{item.lastMessage}
</Text>
<View style={styles.modeContainer}>
<Text style={[styles.modeText, { color: colors.text + '80' }]}>
{item.mode === 'frei' ? 'Freier Modus' :
item.mode === 'geführt' ? 'Geführter Modus' : 'Vorlagen-Modus'}
</Text>
</View>
</View>
<TouchableOpacity onPress={() => toggleOptionsMenu(item.id)}>
<Ionicons name="ellipsis-vertical" size={20} color={colors.text + '80'} />
</TouchableOpacity>
</TouchableOpacity>
{showOptions && (
<View style={[styles.optionsContainer, { backgroundColor: colors.card }]}>
<TouchableOpacity
style={styles.optionButton}
onPress={() => handleUnarchiveConversation(item.id)}
>
<Ionicons name="arrow-undo-outline" size={18} color={colors.text} />
<Text style={[styles.optionText, { color: colors.text }]}>Wiederherstellen</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.optionButton}
onPress={() => handleDeleteConversation(item.id)}
>
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Löschen</Text>
</TouchableOpacity>
</View>
)}
</View>
);
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.mainLayout}>
{/* Permanenter Drawer links */}
{isDrawerOpen && (
<View style={styles.drawerContainer}>
<CustomDrawer
isVisible={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
/>
</View>
)}
{/* Hauptinhalt */}
<View style={styles.mainContainer}>
<View style={styles.contentContainer}>
<View style={styles.headerContainer}>
<TouchableOpacity
style={styles.menuButton}
onPress={() => setIsDrawerOpen(!isDrawerOpen)}
>
<Ionicons
name="menu-outline"
size={28}
color={colors.text}
/>
</TouchableOpacity>
<View style={styles.headerContentContainer}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>Archiv</Text>
</View>
</View>
{/* Konversationsliste */}
<View style={styles.listContainer}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
Konversationen werden geladen...
</Text>
</View>
) : conversations.length > 0 ? (
<FlatList
data={conversations}
keyExtractor={(item) => item.id}
renderItem={renderConversationItem}
contentContainerStyle={styles.listContent}
/>
) : (
<View style={styles.emptyContainer}>
<Ionicons
name="archive-outline"
size={64}
color={colors.text + '40'}
/>
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine archivierten Konversationen
</Text>
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
Archivierte Gespräche erscheinen hier
</Text>
</View>
)}
</View>
</View>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
mainLayout: {
flex: 1,
flexDirection: 'row',
},
mainContainer: {
flex: 1,
alignItems: 'center',
},
drawerContainer: {
width: 260,
height: '100%',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
zIndex: 10,
},
contentContainer: {
flex: 1,
maxWidth: 1200,
width: '100%',
},
headerContainer: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 8,
},
menuButton: {
padding: 12,
marginRight: 0,
zIndex: 5,
},
headerContentContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
backButton: {
padding: 8,
marginRight: 8,
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
listContainer: {
flex: 1,
width: '100%',
maxWidth: 800,
alignSelf: 'center',
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 120,
width: '100%',
maxWidth: 800,
alignSelf: 'center',
},
conversationItemWrapper: {
borderRadius: 12,
marginTop: 12,
overflow: 'hidden',
},
conversationItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
},
conversationContent: {
flex: 1,
},
optionsContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingBottom: 12,
paddingTop: 4,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(0,0,0,0.1)',
},
optionButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
marginLeft: 12,
},
optionText: {
fontSize: 14,
marginLeft: 6,
fontWeight: '500',
},
conversationHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
titleIcon: {
marginRight: 8,
},
timestamp: {
fontSize: 12,
},
modelContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
modelName: {
fontSize: 12,
fontWeight: '400',
},
lastMessage: {
fontSize: 14,
marginBottom: 6,
},
modeContainer: {
flexDirection: 'row',
alignItems: 'center',
},
modeText: {
fontSize: 12,
},
// Container für den Ladezustand
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
marginTop: 40,
},
loadingText: {
fontSize: 16,
marginTop: 16,
textAlign: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
marginTop: 40,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
marginTop: 16,
textAlign: 'center',
},
emptySubtext: {
fontSize: 14,
marginTop: 8,
textAlign: 'center',
},
});

View file

@ -0,0 +1,8 @@
import React from 'react';
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }} />
);
}

View file

@ -0,0 +1,295 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter, Link } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../../context/AuthProvider';
import { supabase } from '../../utils/supabase';
import { useAppTheme } from '../../theme/ThemeProvider';
export default function LoginScreen() {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { signIn } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const handleLogin = async () => {
if (!email || !password) {
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse und dein Passwort ein.');
return;
}
try {
setLoading(true);
const { error } = await signIn(email, password);
if (error) {
console.log('Anmeldung mit Passwort fehlgeschlagen, versuche direkte Anmeldung...');
// Wenn die normale Anmeldung fehlschlägt, versuche eine direkte Anmeldung
const { error: directError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (directError) {
Alert.alert('Anmeldung fehlgeschlagen', directError.message);
} else {
router.replace('/');
}
} else {
// Erfolgreich angemeldet, navigiere zur Hauptseite
router.replace('/');
}
} catch (error) {
console.error('Fehler bei der Anmeldung:', error);
Alert.alert('Fehler', 'Bei der Anmeldung ist ein Fehler aufgetreten. Bitte versuche es später erneut.');
} finally {
setLoading(false);
}
};
const handleMagicLink = async () => {
if (!email) {
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse ein.');
return;
}
try {
setLoading(true);
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: 'exp://localhost:8081/',
},
});
if (error) {
Alert.alert('Fehler', error.message);
} else {
setIsMagicLinkSent(true);
Alert.alert(
'Magic Link gesendet',
'Wir haben dir einen Magic Link an deine E-Mail-Adresse gesendet. Bitte öffne den Link, um dich anzumelden.'
);
}
} catch (error) {
console.error('Fehler beim Senden des Magic Links:', error);
Alert.alert('Fehler', 'Beim Senden des Magic Links ist ein Fehler aufgetreten. Bitte versuche es später erneut.');
} finally {
setLoading(false);
}
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>Willkommen zurück</Text>
<Text style={[styles.subtitle, { color: colors.text + 'CC' }]}>
Melde dich an, um deine Konversationen fortzusetzen
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>E-Mail</Text>
<View style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
}
]}>
<Ionicons name="mail-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="deine@email.de"
placeholderTextColor={colors.text + '60'}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
</View>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>Passwort</Text>
<View style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
}
]}>
<Ionicons name="lock-closed-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Passwort"
placeholderTextColor={colors.text + '60'}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
<Ionicons
name={showPassword ? "eye-off-outline" : "eye-outline"}
size={20}
color={colors.text + '80'}
/>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
style={styles.forgotPassword}
onPress={() => router.push('/auth/reset-password')}
>
<Text style={[styles.forgotPasswordText, { color: colors.primary }]}>
Passwort vergessen?
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.loginButton,
{ backgroundColor: colors.primary },
loading && { opacity: 0.7 }
]}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.loginButtonText}>Anmelden</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.magicLinkButton,
{ backgroundColor: 'transparent', borderColor: colors.primary, borderWidth: 1 },
loading && { opacity: 0.7 }
]}
onPress={handleMagicLink}
disabled={loading || isMagicLinkSent}
>
{loading ? (
<ActivityIndicator color={colors.primary} size="small" />
) : (
<Text style={[styles.magicLinkButtonText, { color: colors.primary }]}>
{isMagicLinkSent ? 'Magic Link gesendet' : 'Mit Magic Link anmelden'}
</Text>
)}
</TouchableOpacity>
<View style={styles.signupContainer}>
<Text style={[styles.signupText, { color: colors.text + 'CC' }]}>
Noch kein Konto?
</Text>
<Link href="/auth/register" asChild>
<TouchableOpacity>
<Text style={[styles.signupLink, { color: colors.primary }]}>
Registrieren
</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
},
header: {
marginTop: 40,
marginBottom: 40,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
},
form: {
width: '100%',
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
},
input: {
flex: 1,
fontSize: 16,
marginLeft: 12,
},
forgotPassword: {
alignSelf: 'flex-end',
marginBottom: 24,
},
forgotPasswordText: {
fontSize: 14,
fontWeight: '600',
},
loginButton: {
height: 56,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
magicLinkButton: {
height: 56,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
magicLinkButtonText: {
fontSize: 16,
fontWeight: '600',
},
loginButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
signupContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
signupText: {
fontSize: 14,
marginRight: 4,
},
signupLink: {
fontSize: 14,
fontWeight: '600',
},
});

View file

@ -0,0 +1,244 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter, Link } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../../context/AuthProvider';
import { useAppTheme } from '../../theme/ThemeProvider';
export default function RegisterScreen() {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { signUp } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleRegister = async () => {
if (!email || !password || !confirmPassword) {
Alert.alert('Fehler', 'Bitte fülle alle Felder aus.');
return;
}
if (password !== confirmPassword) {
Alert.alert('Fehler', 'Die Passwörter stimmen nicht überein.');
return;
}
if (password.length < 6) {
Alert.alert('Fehler', 'Das Passwort muss mindestens 6 Zeichen lang sein.');
return;
}
try {
setLoading(true);
const { data, error } = await signUp(email, password);
if (error) {
Alert.alert('Registrierung fehlgeschlagen', error.message);
} else if (data?.user) {
Alert.alert(
'Registrierung erfolgreich',
'Dein Konto wurde erfolgreich erstellt. Du wirst jetzt angemeldet.',
[
{
text: 'OK',
onPress: () => router.replace('/')
}
]
);
}
} catch (error) {
console.error('Fehler bei der Registrierung:', error);
Alert.alert('Fehler', 'Bei der Registrierung ist ein Fehler aufgetreten. Bitte versuche es später erneut.');
} finally {
setLoading(false);
}
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>Konto erstellen</Text>
<Text style={[styles.subtitle, { color: colors.text + 'CC' }]}>
Erstelle ein Konto, um mit KI-Modellen zu chatten
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>E-Mail</Text>
<View style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
}
]}>
<Ionicons name="mail-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="deine@email.de"
placeholderTextColor={colors.text + '60'}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
</View>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>Passwort</Text>
<View style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
}
]}>
<Ionicons name="lock-closed-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Passwort"
placeholderTextColor={colors.text + '60'}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
<Ionicons
name={showPassword ? "eye-off-outline" : "eye-outline"}
size={20}
color={colors.text + '80'}
/>
</TouchableOpacity>
</View>
</View>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>Passwort bestätigen</Text>
<View style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
}
]}>
<Ionicons name="lock-closed-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Passwort bestätigen"
placeholderTextColor={colors.text + '60'}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry={!showPassword}
/>
</View>
</View>
<TouchableOpacity
style={[
styles.registerButton,
{ backgroundColor: colors.primary },
loading && { opacity: 0.7 }
]}
onPress={handleRegister}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.registerButtonText}>Registrieren</Text>
)}
</TouchableOpacity>
<View style={styles.loginContainer}>
<Text style={[styles.loginText, { color: colors.text + 'CC' }]}>
Bereits ein Konto?
</Text>
<Link href="/auth/login" asChild>
<TouchableOpacity>
<Text style={[styles.loginLink, { color: colors.primary }]}>
Anmelden
</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
},
header: {
marginTop: 40,
marginBottom: 40,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
},
form: {
width: '100%',
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
},
input: {
flex: 1,
fontSize: 16,
marginLeft: 12,
},
registerButton: {
height: 56,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginTop: 12,
marginBottom: 24,
},
registerButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
loginContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
loginText: {
fontSize: 14,
marginRight: 4,
},
loginLink: {
fontSize: 14,
fontWeight: '600',
},
});

View file

@ -0,0 +1,172 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../../context/AuthProvider';
import { useAppTheme } from '../../theme/ThemeProvider';
export default function ResetPasswordScreen() {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { resetPassword } = useAuth();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const handleResetPassword = async () => {
if (!email) {
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse ein.');
return;
}
try {
setLoading(true);
const { error } = await resetPassword(email);
if (error) {
Alert.alert('Fehler', error.message);
} else {
Alert.alert(
'E-Mail gesendet',
'Eine E-Mail mit Anweisungen zum Zurücksetzen deines Passworts wurde an deine E-Mail-Adresse gesendet.',
[
{
text: 'OK',
onPress: () => router.replace('/auth/login')
}
]
);
}
} catch (error) {
console.error('Fehler beim Zurücksetzen des Passworts:', error);
Alert.alert('Fehler', 'Beim Zurücksetzen des Passworts ist ein Fehler aufgetreten. Bitte versuche es später erneut.');
} finally {
setLoading(false);
}
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>Passwort zurücksetzen</Text>
<Text style={[styles.subtitle, { color: colors.text + 'CC' }]}>
Gib deine E-Mail-Adresse ein, um einen Link zum Zurücksetzen deines Passworts zu erhalten
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>E-Mail</Text>
<View style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
}
]}>
<Ionicons name="mail-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="deine@email.de"
placeholderTextColor={colors.text + '60'}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
</View>
<TouchableOpacity
style={[
styles.resetButton,
{ backgroundColor: colors.primary },
loading && { opacity: 0.7 }
]}
onPress={handleResetPassword}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.resetButtonText}>Link senden</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Text style={[styles.backButtonText, { color: colors.text }]}>
Zurück zur Anmeldung
</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
},
header: {
marginTop: 40,
marginBottom: 40,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
},
form: {
width: '100%',
},
inputContainer: {
marginBottom: 24,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
},
input: {
flex: 1,
fontSize: 16,
marginLeft: 12,
},
resetButton: {
height: 56,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
resetButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
backButton: {
alignItems: 'center',
padding: 12,
},
backButtonText: {
fontSize: 16,
fontWeight: '500',
},
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,129 @@
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { View, ActivityIndicator, StyleSheet, Text } from 'react-native';
import { createConversation, sendMessageAndGetResponse } from '../../../services/conversation';
import { useAuth } from '../../../context/AuthProvider';
import { Alert } from 'react-native';
// Typendefinition für Parameter
interface ConversationNewParams {
initialMessage?: string;
modelId?: string;
templateId?: string;
mode?: 'free' | 'guided' | 'template';
documentMode?: string; // String, da Query-Parameter immer Strings sind
spaceId?: string; // ID des Space, falls vorhanden
}
export default function NewConversation() {
const { user } = useAuth();
const router = useRouter();
const params = useLocalSearchParams<ConversationNewParams>();
const [isFetching, setIsFetching] = useState(true);
// Extrahiere die Parameter
const initialMessage = params?.initialMessage || '';
const modelId = params?.modelId || '550e8400-e29b-41d4-a716-446655440000'; // Default zu GPT-4o-mini
const templateId = params?.templateId;
const mode = (params?.mode || 'free') as 'free' | 'guided' | 'template';
const documentMode = params?.documentMode === 'true';
const spaceId = params?.spaceId;
console.log('Erhaltene Parameter:', {
initialMessage: initialMessage.substring(0, 50),
modelId,
templateId,
mode,
documentMode,
spaceId: spaceId || 'nicht angegeben'
});
// Log für Debug-Zwecke
console.log("⭐️ Neue Konversation wird erstellt mit Space ID:", spaceId || "keine");
useEffect(() => {
if (!user) {
console.error('Kein Benutzer gefunden');
router.replace('/auth/login');
return;
}
if (!initialMessage) {
console.warn('Keine Nachricht gefunden');
router.replace('/');
return;
}
const startConversation = async () => {
try {
setIsFetching(true);
console.log('Erstelle Konversation...');
// 1. Erstelle eine neue Konversation
const conversationId = await createConversation(
user.id,
modelId,
mode,
templateId,
documentMode,
spaceId
);
if (!conversationId) {
throw new Error('Fehler beim Erstellen der Konversation');
}
console.log('Konversation erstellt mit ID:', conversationId);
// 2. Sende die initiale Nachricht
const response = await sendMessageAndGetResponse(
conversationId,
initialMessage,
modelId,
templateId,
documentMode
);
console.log('Antwort erhalten');
// 3. Navigiere zur Konversation
router.replace(`/conversation/${conversationId}`);
} catch (error) {
console.error('Fehler beim Starten der Konversation:', error);
Alert.alert(
'Fehler',
'Die Konversation konnte nicht gestartet werden.',
[
{
text: 'OK',
onPress: () => router.replace('/')
}
]
);
} finally {
setIsFetching(false);
}
};
startConversation();
}, [user, initialMessage, modelId, templateId, mode, documentMode, spaceId, router]);
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#0000ff" />
<Text style={styles.text}>Starte Konversation...</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
text: {
marginTop: 20,
fontSize: 16,
}
});

View file

@ -0,0 +1,604 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
SafeAreaView,
Alert,
ActivityIndicator,
Pressable,
Platform,
Dimensions
} from 'react-native';
import { useTheme, useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../context/AuthProvider';
import { useAppTheme } from '../theme/ThemeProvider';
import CustomDrawer from '../components/CustomDrawer';
import {
getConversations,
getMessages,
deleteConversation,
archiveConversation
} from '../services/conversation';
import { supabase } from '../utils/supabase';
// Typendefinitionen für Konversationen
type ConversationItem = {
id: string;
modelName: string;
title: string;
lastMessage: string;
timestamp: Date;
mode: 'frei' | 'geführt' | 'vorlage';
};
// Hilfsfunktion zur Formatierung des Datums
const formatDate = (date: Date) => {
const day = date.getDate().toString().padStart(2, '0');
const month = new Intl.DateTimeFormat('de-DE', { month: 'short' }).format(date);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}. ${month}, ${hours}:${minutes}`;
};
export default function ConversationsScreen() {
const { colors } = useTheme();
const router = useRouter();
const { user } = useAuth();
const [conversations, setConversations] = useState<ConversationItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { isDarkMode } = useAppTheme();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
// Eine Funktion, die Konversationen lädt und wiederverwendet werden kann
const loadConversations = async () => {
if (!user) return;
setIsLoading(true);
try {
console.log("Lade Konversationen für User:", user.id);
// Lade alle nicht-archivierten Konversationen des Benutzers
const userConversations = await getConversations(user.id);
console.log(`${userConversations.length} Konversationen geladen`, new Date().toLocaleTimeString());
// Lade für jede Konversation die letzte Nachricht und das Modell
const conversationItems: ConversationItem[] = [];
for (const conv of userConversations) {
try {
// Lade die Nachrichten der Konversation
const messages = await getMessages(conv.id);
// Lade das Modell aus der Datenbank
const { data: modelData } = await supabase
.from('models')
.select('name')
.eq('id', conv.model_id)
.single();
// Finde die letzte Nachricht (die nicht vom System ist)
const lastMessage = messages
.filter(msg => msg.sender !== 'system')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
if (lastMessage) {
conversationItems.push({
id: conv.id,
modelName: modelData?.name || 'Unbekanntes Modell',
title: conv.title || 'Unbenannte Konversation',
lastMessage: lastMessage.message_text,
timestamp: new Date(conv.updated_at),
mode: conv.conversation_mode === 'free' ? 'frei' :
conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage'
});
}
} catch (error) {
console.error(`Fehler beim Laden der Details für Konversation ${conv.id}:`, error);
}
}
setConversations(conversationItems);
} catch (error) {
console.error('Fehler beim Laden der Konversationen:', error);
Alert.alert('Fehler', 'Die Konversationen konnten nicht geladen werden.');
} finally {
setIsLoading(false);
}
};
// Lade die Konversationen beim ersten Rendern und wenn sich der User ändert
useEffect(() => {
loadConversations();
}, [user]);
// Lade Konversationen erneut, wenn der Screen fokussiert wird
useFocusEffect(
useCallback(() => {
if (user) loadConversations();
return () => {};
}, [user])
);
const handleConversationPress = (id: string) => {
// Navigiere zum Konversations-Screen mit der ID
router.push(`/conversation/${id}`);
};
// Löschen einer Konversation
const handleDeleteConversation = (id: string) => {
Alert.alert(
"Konversation löschen",
"Möchtest du diese Konversation wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
[
{
text: "Abbrechen",
style: "cancel"
},
{
text: "Löschen",
style: "destructive",
onPress: async () => {
try {
const success = await deleteConversation(id);
if (success) {
// Aus der lokalen Liste entfernen
setConversations(prev => prev.filter(conv => conv.id !== id));
Alert.alert("Erfolg", "Die Konversation wurde gelöscht.");
} else {
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
}
} catch (error) {
console.error('Fehler beim Löschen der Konversation:', error);
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
}
}
}
]
);
};
// Archivieren einer Konversation
const handleArchiveConversation = async (id: string) => {
try {
const success = await archiveConversation(id);
if (success) {
// Aus der lokalen Liste entfernen
setConversations(prev => prev.filter(conv => conv.id !== id));
Alert.alert("Erfolg", "Die Konversation wurde archiviert.");
} else {
Alert.alert("Fehler", "Die Konversation konnte nicht archiviert werden.");
}
} catch (error) {
console.error('Fehler beim Archivieren der Konversation:', error);
Alert.alert("Fehler", "Die Konversation konnte nicht archiviert werden.");
}
};
// Zustandsverwaltung für die Optionsmenüs der Konversationselemente
const [expandedConversationId, setExpandedConversationId] = useState<string | null>(null);
// Toggle-Funktion für das Optionsmenü
const toggleOptionsMenu = (id: string) => {
setExpandedConversationId(expandedConversationId === id ? null : id);
};
const renderConversationItem = ({ item }: { item: ConversationItem }) => {
const showOptions = expandedConversationId === item.id;
return (
<View style={[
styles.conversationItemWrapper,
{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2
}
]}>
<Pressable
style={({ pressed, hovered }) => [
styles.conversationItem,
hovered && { backgroundColor: colors.cardHover },
pressed && { opacity: 0.9 }
]}
onPress={() => handleConversationPress(item.id)}
onLongPress={() => toggleOptionsMenu(item.id)}
>
{({ pressed, hovered }) => (
<>
<View style={styles.conversationContent}>
<View style={styles.conversationHeader}>
<View style={styles.titleRow}>
<Ionicons
name="chatbubble-ellipses-outline"
size={18}
color={colors.primary}
style={styles.titleIcon}
/>
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
{item.title}
</Text>
</View>
</View>
<View style={styles.badgeContainer}>
<View style={[styles.modelBadge, { backgroundColor: colors.primary + '15' }]}>
<Text style={[styles.modelName, { color: colors.primary }]}>
{item.modelName}
</Text>
</View>
<View style={[styles.modeBadge, { backgroundColor: colors.muted + '30' }]}>
<Text style={[styles.modeText, { color: colors.text + '90' }]}>
{item.mode === 'frei' ? 'Frei' :
item.mode === 'geführt' ? 'Geführt' : 'Vorlage'}
</Text>
</View>
<Text style={[styles.timestamp, { color: colors.text + '80' }]}>
{formatDate(item.timestamp)}
</Text>
</View>
<Text
style={[styles.lastMessage, { color: colors.text + 'CC' }]}
numberOfLines={3}
>
{item.lastMessage}
</Text>
</View>
<Pressable
style={({ pressed, hovered }) => [
styles.optionsButton,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.7 }
]}
onPress={() => toggleOptionsMenu(item.id)}
>
{({ pressed, hovered }) => (
<Ionicons
name="ellipsis-vertical"
size={20}
color={colors.text + '80'}
/>
)}
</Pressable>
</>
)}
</Pressable>
{showOptions && (
<View style={[styles.optionsContainer, {
backgroundColor: colors.card,
borderTopWidth: 1,
borderTopColor: colors.border
}]}>
<Pressable
style={({ pressed, hovered }) => [
styles.optionButton,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.8 }
]}
onPress={() => handleArchiveConversation(item.id)}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="archive-outline" size={18} color={colors.text} />
<Text style={[styles.optionText, { color: colors.text }]}>Archivieren</Text>
</>
)}
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.optionButton,
hovered && { backgroundColor: colors.dangerHover },
pressed && { opacity: 0.8 }
]}
onPress={() => handleDeleteConversation(item.id)}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Löschen</Text>
</>
)}
</Pressable>
</View>
)}
</View>
);
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.mainLayout}>
{/* Permanenter Drawer links */}
{isDrawerOpen && (
<View style={styles.drawerContainer}>
<CustomDrawer
isVisible={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
/>
</View>
)}
{/* Hauptinhalt */}
<View style={styles.mainContainer}>
<View style={styles.contentContainer}>
<View style={styles.headerContainer}>
<Pressable
style={({ pressed, hovered }) => [
styles.menuButton,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.7 }
]}
onPress={() => setIsDrawerOpen(!isDrawerOpen)}
>
{({ pressed, hovered }) => (
<Ionicons
name="menu-outline"
size={28}
color={colors.text}
/>
)}
</Pressable>
<Text style={[styles.headerTitle, { color: colors.text }]}>Konversationen</Text>
</View>
{/* Konversationsliste */}
<View style={styles.listContainer}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
Konversationen werden geladen...
</Text>
</View>
) : conversations.length > 0 ? (
<FlatList
data={conversations}
keyExtractor={(item) => item.id}
renderItem={renderConversationItem}
contentContainerStyle={styles.listContent}
numColumns={Platform.OS === 'web' ? Math.min(Math.floor((Dimensions.get('window').width - 32) / 400), 3) : 1}
key={Platform.OS === 'web' ? Math.min(Math.floor((Dimensions.get('window').width - 32) / 400), 3) : 1}
/>
) : (
<View style={styles.emptyContainer}>
<Ionicons
name="chatbubbles-outline"
size={64}
color={colors.text + '40'}
/>
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine Konversationen vorhanden
</Text>
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
Starte eine neue Konversation über den Hauptbildschirm
</Text>
</View>
)}
</View>
</View>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
mainLayout: {
flex: 1,
flexDirection: 'row',
},
mainContainer: {
flex: 1,
alignItems: 'center',
},
drawerContainer: {
width: 260,
height: '100%',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
zIndex: 10,
},
contentContainer: {
flex: 1,
maxWidth: 1200,
width: '100%',
},
headerContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 8,
zIndex: 10, // Stelle sicher, dass der Header über allem anderen liegt
elevation: 10, // Für Android
},
menuButton: {
padding: 10,
marginRight: 12,
zIndex: 5,
borderRadius: 20,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 28,
fontWeight: 'bold',
},
listContainer: {
flex: 1,
width: '100%',
paddingHorizontal: 16,
},
listContent: {
paddingBottom: 20,
paddingTop: 12,
gap: 16,
alignSelf: 'center',
justifyContent: Platform.OS === 'web' ? 'flex-start' : undefined,
},
conversationItemWrapper: {
borderRadius: 12,
overflow: 'hidden',
margin: 8,
width: Platform.OS === 'web' ? 380 : undefined,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 3,
},
web: {
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
},
}),
},
conversationItem: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
},
conversationContent: {
flex: 1,
display: 'flex',
flexDirection: 'column',
height: '100%',
},
optionsContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingBottom: 12,
paddingTop: 8,
},
optionButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
marginLeft: 8,
borderRadius: 6,
},
optionText: {
fontSize: 14,
marginLeft: 6,
fontWeight: '500',
},
conversationHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
titleIcon: {
marginRight: 8,
},
title: {
fontSize: 16,
fontWeight: '600',
flex: 1,
marginBottom: 2,
},
badgeContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
gap: 8,
flexWrap: 'wrap',
},
modelBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
},
modelName: {
fontSize: 12,
fontWeight: '500',
},
modeBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
},
timestamp: {
fontSize: 11,
marginLeft: 'auto', // Um es an den rechten Rand zu schieben
},
lastMessage: {
fontSize: 14,
marginBottom: 6,
lineHeight: 20,
marginTop: 4,
flex: 1, // Damit die Nachricht den verbleibenden Platz einnimmt
},
modeText: {
fontSize: 11,
fontWeight: '500',
},
optionsButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
// Container für den Ladezustand
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
marginTop: 40,
},
loadingText: {
fontSize: 16,
marginTop: 16,
textAlign: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
marginTop: 40,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
marginTop: 16,
textAlign: 'center',
},
emptySubtext: {
fontSize: 14,
textAlign: 'center',
marginTop: 8,
},
});

View file

@ -0,0 +1,465 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
useWindowDimensions,
Platform
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Document } from '../services/document';
import { supabase } from '../utils/supabase';
import Markdown from 'react-native-markdown-display';
type DocumentWithTitle = Document & {
conversation_title: string;
};
export default function DocumentsScreen() {
const { colors } = useTheme();
const router = useRouter();
const { width } = useWindowDimensions();
const [documents, setDocuments] = useState<DocumentWithTitle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [userId, setUserId] = useState<string | null>(null);
// Berechne die Anzahl der Spalten basierend auf der Bildschirmbreite
const columnsCount = useMemo(() => {
// Mobile (schmaler Bildschirm)
if (width < 600) {
return 1;
}
// Tablet
if (width < 1100) {
return 2;
}
// Desktop oder großes Tablet
return 3;
}, [width]);
// Berechne die Breite jeder Karte basierend auf der Spaltenanzahl
const cardWidth = useMemo(() => {
const padding = 16; // Container-Padding rechts und links
const gap = 16; // Abstand zwischen Karten
const contentWidth = width - (padding * 2);
const gapTotal = gap * (columnsCount - 1);
const availableWidth = contentWidth - gapTotal;
// Verhältnis für schmalere Karten, je nach Spaltenanzahl anpassen
const widthRatio = columnsCount === 1 ? 0.95 : // Fast volle Breite bei 1 Spalte
columnsCount === 2 ? 0.48 : // Etwas schmaler bei 2 Spalten
0.31; // Noch schmaler bei 3 Spalten
return (availableWidth * widthRatio);
}, [width, columnsCount]);
useEffect(() => {
const checkUser = async () => {
const { data } = await supabase.auth.getUser();
if (data?.user) {
setUserId(data.user.id);
} else {
// In einer echten App würden wir hier zur Login-Seite weiterleiten
// Für jetzt verwenden wir eine Test-ID
setUserId('test-user-id');
}
};
checkUser();
}, []);
useEffect(() => {
if (userId) {
loadDocuments();
}
}, [userId]);
const loadDocuments = async () => {
try {
setIsLoading(true);
// Lade alle Konversationen des Benutzers, die im Dokumentmodus sind
const { data: conversations, error: convError } = await supabase
.from('conversations')
.select('id, title, document_mode')
.eq('user_id', userId)
.eq('document_mode', true);
if (convError) {
console.error('Fehler beim Laden der Konversationen:', convError);
setIsLoading(false);
return;
}
if (!conversations || conversations.length === 0) {
setDocuments([]);
setIsLoading(false);
return;
}
// Für jede Konversation den neuesten Dokumentstand laden
const latestDocuments: DocumentWithTitle[] = [];
for (const conv of conversations) {
const { data: docData, error: docError } = await supabase
.from('documents')
.select('*')
.eq('conversation_id', conv.id)
.order('version', { ascending: false })
.limit(1)
.single();
if (docError) {
if (docError.code !== 'PGRST116') { // Ignore "No rows found" error
console.error(`Fehler beim Laden des Dokuments für Konversation ${conv.id}:`, docError);
}
continue;
}
if (docData) {
latestDocuments.push({
...docData,
conversation_title: conv.title || 'Unbenannte Konversation'
});
}
}
setDocuments(latestDocuments);
} catch (error) {
console.error('Fehler beim Laden der Dokumente:', error);
} finally {
setIsLoading(false);
}
};
const navigateToConversation = (conversationId: string) => {
router.push(`/conversation/${conversationId}`);
};
// Funktion zum Extrahieren eines Titels aus dem Dokumentinhalt
const extractDocumentTitle = (content: string): string => {
// Suche nach einer Markdown-Überschrift Ebene 1 am Anfang
const titleMatch = content.match(/^#\s+(.+)$/m);
if (titleMatch && titleMatch[1]) {
return titleMatch[1].trim();
}
// Alternativ: Suche nach einer Markdown-Überschrift Ebene 2
const subtitleMatch = content.match(/^##\s+(.+)$/m);
if (subtitleMatch && subtitleMatch[1]) {
return subtitleMatch[1].trim();
}
// Wenn keine Überschrift gefunden wurde, nimm die ersten Wörter
const firstLine = content.split('\n')[0].trim();
if (firstLine.length > 0) {
return firstLine.length > 40 ? `${firstLine.substring(0, 37)}...` : firstLine;
}
return 'Dokument ohne Titel';
};
// Funktion zum Entfernen nur der ersten H1-Überschrift aus dem Inhalt
const removeHeadingFromContent = (content: string, title: string): string => {
// Prüfe, ob das Dokument mit einer H1-Überschrift beginnt
const firstLineMatch = content.match(/^#\s+(.+)$/m);
if (firstLineMatch && firstLineMatch.index === 0) {
// Entferne nur die erste H1-Überschrift am Anfang des Dokuments
const parts = content.split('\n');
parts.shift(); // Entferne die erste Zeile (H1-Überschrift)
// Entferne leere Zeilen am Anfang
let modifiedContent = parts.join('\n').replace(/^\s+/, '');
return modifiedContent;
}
// Wenn keine H1-Überschrift am Anfang gefunden wurde,
// gib den ursprünglichen Inhalt zurück
return content;
};
// Funktion zum Formatieren des Datums
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
return `${day}.${month}.${year}`;
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: colors.text }]}>Alle Dokumente</Text>
<TouchableOpacity style={styles.refreshButton} onPress={loadDocuments}>
<Ionicons name="refresh" size={24} color={colors.text} />
</TouchableOpacity>
</View>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text }]}>
Dokumente werden geladen...
</Text>
</View>
) : documents.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons name="document-text-outline" size={64} color={colors.text} style={styles.emptyIcon} />
<Text style={[styles.emptyText, { color: colors.text }]}>
Keine Dokumente gefunden
</Text>
<Text style={[styles.emptySubtext, { color: colors.text }]}>
Erstelle ein neues Dokument in einer Konversation mit aktiviertem Dokumentmodus.
</Text>
</View>
) : (
<ScrollView style={styles.scrollContainer} contentContainerStyle={styles.documentsContainer}>
{documents.map((doc) => (
<TouchableOpacity
key={doc.id}
style={[
styles.documentCard,
{
backgroundColor: colors.card,
borderColor: colors.border,
width: cardWidth,
// Keine quadratischen Karten mehr, stattdessen festgelegte Höhen
height: 280,
minHeight: 220,
maxHeight: 320
}
]}
onPress={() => navigateToConversation(doc.conversation_id)}
>
<View style={styles.documentHeader}>
<Text style={[styles.documentTitle, { color: colors.text }]}>
{extractDocumentTitle(doc.content)}
</Text>
<View style={styles.documentMeta}>
<Text style={[styles.conversationTitle, { color: colors.text }]}>
{doc.conversation_title}
</Text>
<View style={styles.metaRight}>
<Text style={[styles.documentDate, { color: colors.text }]}>
{formatDate(doc.updated_at)}
</Text>
<Text style={[styles.documentVersion, { color: colors.text }]}>
v{doc.version}
</Text>
</View>
</View>
</View>
<View style={styles.contentContainer}>
<ScrollView style={styles.documentContent} nestedScrollEnabled={true}>
<Markdown
style={{
body: {
color: colors.text,
fontSize: 13,
lineHeight: 18
},
// Normale Anzeige für H1-Überschriften im Inhalt
heading1: {
color: colors.text,
fontSize: 16,
fontWeight: 'bold',
marginTop: 8,
marginBottom: 6,
lineHeight: 20,
paddingBottom: 4,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
},
heading2: {
color: colors.text,
fontSize: 14,
fontWeight: 'bold',
marginVertical: 5,
lineHeight: 18
},
paragraph: {
color: colors.text,
marginBottom: 8,
fontSize: 13,
lineHeight: 18
},
blockquote: {
backgroundColor: colors.card,
borderLeftColor: colors.primary,
borderLeftWidth: 2,
paddingHorizontal: 8,
paddingVertical: 4,
marginVertical: 6
},
code_block: {
backgroundColor: colors.card,
padding: 6,
borderRadius: 3,
fontSize: 12,
lineHeight: 16
},
link: { color: colors.primary }
}}
>
{removeHeadingFromContent(doc.content, extractDocumentTitle(doc.content))}
</Markdown>
</ScrollView>
</View>
</TouchableOpacity>
))}
</ScrollView>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
},
backButton: {
padding: 6,
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
flex: 1,
paddingLeft: 12,
},
refreshButton: {
padding: 6,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
emptyIcon: {
marginBottom: 20,
opacity: 0.6,
},
emptyText: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
textAlign: 'center',
opacity: 0.7,
maxWidth: '80%',
},
scrollContainer: {
flex: 1,
},
documentsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
padding: 16,
// In einem flexiblen Layout nicht mehr space-between verwenden
// sondern einen festen Abstand zwischen Items
gap: 20,
// Alignment um die Karten horizontal zu zentrieren
justifyContent: 'center'
},
documentCard: {
// width wird dynamisch basierend auf columnsCount berechnet
borderRadius: 12,
borderWidth: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
// Shadow für die Karten hinzufügen
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 3,
},
web: {
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
},
}),
},
documentHeader: {
padding: 16,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
},
documentTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 8,
lineHeight: 22,
},
documentMeta: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: 8,
},
conversationTitle: {
fontSize: 12,
opacity: 0.7,
flex: 1,
},
metaRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
documentDate: {
fontSize: 11,
opacity: 0.7,
},
documentVersion: {
fontSize: 12,
fontWeight: 'bold',
backgroundColor: 'rgba(0,0,0,0.1)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 10,
},
contentContainer: {
flex: 1,
// Vorschau-Bereich kleiner machen
maxHeight: 180,
},
documentContent: {
padding: 12,
// Zusätzliche Eigenschaften für einen besseren Vorschaubereich
paddingTop: 8,
},
});

View file

@ -0,0 +1,905 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator, TextInput, Pressable, Platform, ScrollView } from 'react-native';
import { useTheme, useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../context/AuthProvider';
import NewChatButton from '../components/NewChatButton';
import ConversationStarter, { ConversationStarterRef } from '../components/ConversationStarter';
import CustomDrawer from '../components/CustomDrawer';
import { useAppTheme } from '../theme/ThemeProvider';
import { getConversations, getMessages, deleteConversation, archiveConversation } from '../services/conversation';
import { getUserSpaces, Space } from '../services/space';
import { supabase } from '../utils/supabase';
// Typendefinitionen für Konversationen
type ConversationItem = {
id: string;
modelName: string;
title: string;
lastMessage: string;
timestamp: Date;
mode: 'frei' | 'geführt' | 'vorlage';
};
// Hilfsfunktion zur Formatierung des Datums
const formatDate = (date: Date) => {
const day = date.getDate().toString().padStart(2, '0');
const month = new Intl.DateTimeFormat('de-DE', { month: 'short' }).format(date);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${day}. ${month}, ${hours}:${minutes}`;
};
export default function HomeScreen() {
const { colors } = useTheme();
const router = useRouter();
const { user, signOut } = useAuth();
const [conversations, setConversations] = useState<ConversationItem[]>([]);
const [spaces, setSpaces] = useState<Space[]>([]);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingSpaces, setIsLoadingSpaces] = useState(true);
const { isDarkMode } = useAppTheme();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const chatInputRef = useRef<ConversationStarterRef>(null);
// Eine Funktion, die Konversationen lädt und wiederverwendet werden kann
// Fokussiere das Eingabefeld beim ersten Laden
useEffect(() => {
// Kurze Verzögerung, um sicherzustellen, dass die Komponente vollständig gerendert ist
setTimeout(() => {
if (chatInputRef.current) {
chatInputRef.current.focus();
}
}, 300);
}, []);
const loadConversations = async () => {
if (!user) return;
setIsLoading(true);
try {
console.log("Lade Konversationen für User:", user.id);
console.log("Selected Space ID:", selectedSpaceId || "Alle Spaces");
// Lade Konversationen des Benutzers, gefiltert nach Space wenn ausgewählt
const userConversations = await getConversations(user.id, selectedSpaceId || undefined);
console.log(`${userConversations.length} Konversationen geladen`, new Date().toLocaleTimeString());
// Lade für jede Konversation die letzte Nachricht und das Modell
const conversationItems: ConversationItem[] = [];
for (const conv of userConversations) {
try {
// Lade die Nachrichten der Konversation
const messages = await getMessages(conv.id);
// Lade das Modell aus der Datenbank
const { data: modelData } = await supabase
.from('models')
.select('name')
.eq('id', conv.model_id)
.single();
// Finde die letzte Nachricht (die nicht vom System ist)
const lastMessage = messages
.filter(msg => msg.sender !== 'system')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
if (lastMessage) {
conversationItems.push({
id: conv.id,
modelName: modelData?.name || 'Unbekanntes Modell',
title: conv.title || 'Unbenannte Konversation',
lastMessage: lastMessage.message_text,
timestamp: new Date(conv.updated_at),
mode: conv.conversation_mode === 'free' ? 'frei' :
conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage'
});
}
} catch (error) {
console.error(`Fehler beim Laden der Details für Konversation ${conv.id}:`, error);
}
}
setConversations(conversationItems);
} catch (error) {
console.error('Fehler beim Laden der Konversationen:', error);
Alert.alert('Fehler', 'Die Konversationen konnten nicht geladen werden.');
} finally {
setIsLoading(false);
}
};
// Lade Spaces
const loadSpaces = useCallback(async () => {
if (!user) return;
setIsLoadingSpaces(true);
try {
const userSpaces = await getUserSpaces(user.id);
setSpaces(userSpaces);
} catch (error) {
console.error('Fehler beim Laden der Spaces:', error);
} finally {
setIsLoadingSpaces(false);
}
}, [user]);
// Lade die Konversationen beim ersten Rendern und wenn sich der User oder selectedSpaceId ändert
useEffect(() => {
loadConversations();
}, [user, selectedSpaceId]);
// Lade Spaces beim ersten Rendern
useEffect(() => {
loadSpaces();
}, [loadSpaces]);
// Lade Konversationen und Spaces erneut, wenn der Screen fokussiert wird
useFocusEffect(
useCallback(() => {
if (user) {
loadConversations();
loadSpaces();
}
return () => {};
}, [user, loadSpaces, selectedSpaceId])
);
// Space auswählen
const handleSpaceSelect = (spaceId: string | null) => {
console.log("Space ausgewählt:", spaceId);
setSelectedSpaceId(spaceId);
// Alert für Debug-Zwecke
Alert.alert(
"Space ausgewählt",
`Space ID: ${spaceId || 'Alle Spaces'}`
);
};
const handleNewChat = () => {
// Navigiere zum Modellauswahl-Screen
router.push('/model-selection');
};
const handleLogout = async () => {
try {
await signOut();
router.replace('/auth/login');
} catch (error) {
console.error('Fehler beim Abmelden:', error);
Alert.alert('Fehler', 'Bei der Abmeldung ist ein Fehler aufgetreten.');
}
};
const handleConversationPress = (id: string) => {
// Navigiere zum Konversations-Screen mit der ID
router.push(`/conversation/${id}`);
};
// Löschen einer Konversation
const handleDeleteConversation = (id: string) => {
Alert.alert(
"Konversation löschen",
"Möchtest du diese Konversation wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
[
{
text: "Abbrechen",
style: "cancel"
},
{
text: "Löschen",
style: "destructive",
onPress: async () => {
try {
const success = await deleteConversation(id);
if (success) {
// Aus der lokalen Liste entfernen
setConversations(prev => prev.filter(conv => conv.id !== id));
Alert.alert("Erfolg", "Die Konversation wurde gelöscht.");
} else {
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
}
} catch (error) {
console.error('Fehler beim Löschen der Konversation:', error);
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
}
}
}
]
);
};
// Archivieren einer Konversation
const handleArchiveConversation = async (id: string) => {
try {
const success = await archiveConversation(id);
if (success) {
// Aus der lokalen Liste entfernen
setConversations(prev => prev.filter(conv => conv.id !== id));
Alert.alert("Erfolg", "Die Konversation wurde archiviert.");
} else {
Alert.alert("Fehler", "Die Konversation konnte nicht archiviert werden.");
}
} catch (error) {
console.error('Fehler beim Archivieren der Konversation:', error);
Alert.alert("Fehler", "Die Konversation konnte nicht archiviert werden.");
}
};
// Zustandsverwaltung für die Optionsmenüs der Konversationselemente
const [expandedConversationId, setExpandedConversationId] = useState<string | null>(null);
// Toggle-Funktion für das Optionsmenü
const toggleOptionsMenu = (id: string) => {
setExpandedConversationId(expandedConversationId === id ? null : id);
};
const renderConversationItem = ({ item }: { item: ConversationItem }) => {
const showOptions = expandedConversationId === item.id;
return (
<View style={[
styles.conversationItemWrapper,
{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2
}
]}>
<Pressable
style={({ pressed, hovered }) => [
styles.conversationItem,
hovered && { backgroundColor: colors.cardHover },
pressed && { opacity: 0.9 }
]}
onPress={() => handleConversationPress(item.id)}
onLongPress={() => toggleOptionsMenu(item.id)}
>
{({ pressed, hovered }) => (
<>
<View style={styles.conversationContent}>
<View style={styles.conversationHeader}>
<View style={styles.titleRow}>
<Ionicons
name="chatbubble-ellipses-outline"
size={18}
color={colors.primary}
style={styles.titleIcon}
/>
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
{item.title}
</Text>
</View>
</View>
<View style={styles.badgeContainer}>
<View style={[styles.modelBadge, { backgroundColor: colors.primary + '15' }]}>
<Text style={[styles.modelName, { color: colors.primary }]}>
{item.modelName}
</Text>
</View>
<View style={[styles.modeBadge, { backgroundColor: colors.muted + '30' }]}>
<Text style={[styles.modeText, { color: colors.text + '90' }]}>
{item.mode === 'frei' ? 'Frei' :
item.mode === 'geführt' ? 'Geführt' : 'Vorlage'}
</Text>
</View>
<Text style={[styles.timestamp, { color: colors.text + '80' }]}>
{formatDate(item.timestamp)}
</Text>
</View>
<Text
style={[styles.lastMessage, { color: colors.text + 'CC' }]}
numberOfLines={3}
>
{item.lastMessage}
</Text>
</View>
<Pressable
style={({ pressed, hovered }) => [
styles.optionsButton,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.7 }
]}
onPress={() => toggleOptionsMenu(item.id)}
>
{({ pressed, hovered }) => (
<Ionicons
name="ellipsis-vertical"
size={20}
color={colors.text + '80'}
/>
)}
</Pressable>
</>
)}
</Pressable>
{showOptions && (
<View style={[styles.optionsContainer, {
backgroundColor: colors.card,
borderTopWidth: 1,
borderTopColor: colors.border
}]}>
<Pressable
style={({ pressed, hovered }) => [
styles.optionButton,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.8 }
]}
onPress={() => handleArchiveConversation(item.id)}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="archive-outline" size={18} color={colors.text} />
<Text style={[styles.optionText, { color: colors.text }]}>Archivieren</Text>
</>
)}
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.optionButton,
hovered && { backgroundColor: colors.dangerHover },
pressed && { opacity: 0.8 }
]}
onPress={() => handleDeleteConversation(item.id)}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Löschen</Text>
</>
)}
</Pressable>
</View>
)}
</View>
);
};
// Fokussiere das Eingabefeld, wenn der Benutzer auf "Neuen Chat starten" klickt
const handleFocusInput = useCallback(() => {
if (chatInputRef.current) {
chatInputRef.current.focus();
}
}, [chatInputRef]);
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.mainLayout}>
{/* Permanenter Drawer links */}
{isDrawerOpen && (
<View style={styles.drawerContainer}>
<CustomDrawer
isVisible={isDrawerOpen}
focusInputOnHomeNavigate={handleFocusInput}
onClose={() => setIsDrawerOpen(false)}
/>
</View>
)}
{/* Hauptinhalt */}
<View style={styles.mainContainer}>
<View style={styles.contentContainer}>
<View style={styles.header}>
<Pressable
style={({ pressed, hovered }) => [
styles.menuButton,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.7 }
]}
onPress={() => setIsDrawerOpen(!isDrawerOpen)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
{({ pressed, hovered }) => (
<Ionicons
name="menu-outline"
size={28}
color={colors.text}
/>
)}
</Pressable>
<Text style={[styles.title, { color: colors.text }]}>Chats</Text>
</View>
{/* Space-Auswahl */}
{spaces.length > 0 && (
<View style={styles.spaceSelector} pointerEvents="box-none">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.spacePills}
pointerEvents="box-none"
>
<TouchableOpacity
style={[
styles.spacePill,
{
backgroundColor: selectedSpaceId === null
? colors.primary
: 'transparent',
borderColor: colors.primary
}
]}
onPress={() => handleSpaceSelect(null)}
activeOpacity={0.7}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Text style={[
styles.spacePillText,
{
color: selectedSpaceId === null
? 'white'
: colors.primary
}
]}>
Alle
</Text>
</TouchableOpacity>
{spaces.map(space => (
<TouchableOpacity
key={space.id}
style={[
styles.spacePill,
{
backgroundColor: selectedSpaceId === space.id
? colors.primary
: 'transparent',
borderColor: colors.primary
}
]}
onPress={() => handleSpaceSelect(space.id)}
activeOpacity={0.7}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Text style={[
styles.spacePillText,
{
color: selectedSpaceId === space.id
? 'white'
: colors.primary
}
]}>
{space.name}
</Text>
</TouchableOpacity>
))}
<TouchableOpacity
style={[
styles.spacePillAdd,
{
backgroundColor: 'transparent',
borderColor: colors.primary,
borderStyle: 'dashed'
}
]}
onPress={() => router.push('/spaces')}
activeOpacity={0.7}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<View style={styles.spacePillAddContent}>
<Ionicons name="add" size={16} color={colors.primary} />
<Text style={[styles.spacePillAddText, { color: colors.primary }]}>
Verwalten
</Text>
</View>
</TouchableOpacity>
</ScrollView>
</View>
)}
{/* Zentrierter ConversationStarter */}
<View style={styles.centerContainer}>
<ConversationStarter
ref={chatInputRef}
placeholder="Was möchtest du wissen?"
spaceId={selectedSpaceId}
/>
</View>
{/* Konversationsliste unten */}
<View style={styles.bottomSection}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
Letzte Konversationen
</Text>
{conversations.length > 0 && (
<Pressable
style={({ pressed, hovered }) => [
styles.viewAllButton,
hovered && { backgroundColor: colors.buttonHover },
pressed && { opacity: 0.8 }
]}
onPress={() => router.push('/conversations')}
>
{({ pressed, hovered }) => (
<Text style={[styles.viewAllText, { color: colors.primary }]}>
Alle anzeigen
</Text>
)}
</Pressable>
)}
</View>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
Konversationen werden geladen...
</Text>
</View>
) : conversations.length > 0 ? (
<FlatList
data={conversations.slice(0, 10)} // Bis zu 10 letzte Einträge
keyExtractor={(item) => item.id}
renderItem={renderConversationItem}
contentContainerStyle={styles.gridContent}
horizontal={true}
showsHorizontalScrollIndicator={false}
snapToAlignment="start"
decelerationRate="fast"
snapToInterval={396} // 380px Kartenbreite + 16px Abstand
/>
) : (
<View style={styles.emptyContainer}>
<Ionicons
name="chatbubbles-outline"
size={64}
color={colors.text + '40'}
/>
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine Konversationen vorhanden
</Text>
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
Stelle eine Frage im Eingabefeld oben
</Text>
</View>
)}
</View>
</View>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
mainLayout: {
flex: 1,
flexDirection: 'row',
},
mainContainer: {
flex: 1,
alignItems: 'center',
},
drawerContainer: {
width: 260,
height: '100%',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
zIndex: 10,
},
contentContainer: {
flex: 1,
maxWidth: 1200,
width: '100%',
},
header: {
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
maxWidth: 800,
width: '100%',
alignSelf: 'center',
zIndex: 10, // Stelle sicher, dass der Header über allem anderen liegt
elevation: 10, // Für Android
},
menuButton: {
padding: 10,
marginRight: 8,
zIndex: 5,
borderRadius: 20,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 28,
fontWeight: 'bold',
},
spaceSelector: {
paddingTop: 8,
paddingBottom: 12,
zIndex: 20, // Erhöht, um über anderen Elementen zu liegen
elevation: 20, // Für Android
position: 'relative', // Setzt einen neuen Stacking-Kontext
},
spacePills: {
paddingHorizontal: 16,
gap: 8,
},
spacePill: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
minWidth: 60,
minHeight: 32,
justifyContent: 'center',
alignItems: 'center',
zIndex: 25, // Noch höher als spaceSelector
elevation: 25, // Für Android
},
spacePillText: {
fontSize: 14,
fontWeight: '500',
},
spacePillAdd: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderStyle: 'dashed',
minWidth: 100,
minHeight: 32,
justifyContent: 'center',
alignItems: 'center',
zIndex: 25, // Gleich wie normaler spacePill
elevation: 25, // Für Android
},
spacePillAddContent: {
flexDirection: 'row',
alignItems: 'center',
},
spacePillAddText: {
fontSize: 14,
fontWeight: '500',
marginLeft: 4,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 16,
marginTop: 20, // Erhöht, um mehr Platz für Space-Pills zu lassen
zIndex: 10, // Zwischen Space-Selector und den Pills
},
bottomSection: {
flex: 0.4, // Nimmt 40% des verfügbaren Platzes ein
width: '100%',
},
gridContent: {
paddingLeft: 16,
paddingRight: 4, // Reduziertes Padding rechts, da die Karten marginRight haben
paddingBottom: 20,
paddingTop: 10,
},
conversationItemWrapper: {
borderRadius: 12,
overflow: 'hidden',
width: 380, // Breitere Karten
height: 180, // Feste Höhe für einheitlichere Darstellung
marginRight: 16, // Abstand zwischen den Karten
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 3,
},
web: {
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
},
}),
},
conversationItem: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
},
conversationContent: {
flex: 1,
display: 'flex',
flexDirection: 'column',
height: '100%',
},
optionsContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingBottom: 12,
paddingTop: 8,
},
optionButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
marginLeft: 8,
borderRadius: 6,
},
optionText: {
fontSize: 14,
marginLeft: 6,
fontWeight: '500',
},
conversationHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
titleIcon: {
marginRight: 8,
},
title: {
fontSize: 16,
fontWeight: '600',
flex: 1,
marginBottom: 2,
},
modelName: {
fontSize: 12,
fontWeight: '400',
},
badgeContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
gap: 8,
flexWrap: 'wrap',
},
modelBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
},
modelName: {
fontSize: 12,
fontWeight: '500',
},
modeBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
},
timestamp: {
fontSize: 11,
marginLeft: 'auto', // Um es an den rechten Rand zu schieben
},
lastMessage: {
fontSize: 14,
marginBottom: 6,
lineHeight: 20,
marginTop: 4,
flex: 1, // Damit die Nachricht den verbleibenden Platz einnimmt
},
modeText: {
fontSize: 11,
fontWeight: '500',
},
optionsButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
// Container für den Ladezustand
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
marginTop: -40,
},
loadingText: {
fontSize: 16,
marginTop: 16,
textAlign: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
marginTop: -80, // Nach oben verschieben, um Platz für das Eingabefeld zu machen
},
emptyText: {
fontSize: 18,
fontWeight: '600',
marginTop: 16,
textAlign: 'center',
},
emptySubtext: {
fontSize: 14,
textAlign: 'center',
marginTop: 8,
},
userContainer: {
flexDirection: 'column',
alignItems: 'flex-end',
},
userEmail: {
fontSize: 12,
marginBottom: 4,
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
},
logoutText: {
color: 'white',
fontSize: 12,
marginLeft: 4,
fontWeight: '500',
marginTop: 8,
textAlign: 'center',
},
buttonContainer: {
position: 'absolute',
bottom: 24,
right: 24,
},
sectionHeader: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 4,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
maxWidth: 800,
alignSelf: 'center',
width: '100%',
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
},
viewAllButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
viewAllText: {
fontSize: 14,
fontWeight: '500',
},
});

View file

@ -0,0 +1,178 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, FlatList, SafeAreaView, TouchableOpacity } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import ModelCard from '../components/ModelCard';
import { getModels } from '../services/modelService';
import { Model } from '../types';
import { availableModels } from '../config/azure';
export default function ModelSelectionScreen() {
const { colors } = useTheme();
const router = useRouter();
const params = useLocalSearchParams();
const initialMessage = params.initialMessage as string || '';
const [models, setModels] = useState<Model[]>(availableModels);
const [selectedModelId, setSelectedModelId] = useState<string>(availableModels[0].id);
const [loading, setLoading] = useState(true);
// Extrahiere mögliche Space ID aus den Parametern
const spaceId = params.spaceId as string || null;
useEffect(() => {
// Lade Modelle vom Service
const loadModels = async () => {
try {
setLoading(true);
const modelsList = await getModels();
setModels(modelsList);
// Setze das erste Modell als Standard, wenn noch keins ausgewählt ist
if (!selectedModelId && modelsList.length > 0) {
setSelectedModelId(modelsList[0].id);
}
} catch (error) {
console.error('Fehler beim Laden der Modelle:', error);
} finally {
setLoading(false);
}
};
loadModels();
}, []);
const handleSelectModel = (id: string) => {
setSelectedModelId(id);
};
const handleStart = () => {
// Navigiere zum Konversationsscreen mit ausgewähltem Modell und initialem Text
router.push({
pathname: '/conversation/new',
params: {
initialMessage,
modelId: selectedModelId,
mode: 'free',
...(spaceId && { spaceId }) // Füge spaceId hinzu, wenn vorhanden
}
});
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>
Modell auswählen
</Text>
</View>
<Text style={[styles.subtitle, { color: colors.text + 'CC' }]}>
Wähle das KI-Modell, mit dem du chatten möchtest
</Text>
{loading ? (
<View style={styles.loadingContainer}>
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
Modelle werden geladen...
</Text>
</View>
) : (
<FlatList
data={models}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ModelCard
id={item.id}
name={item.name}
description={item.description}
isSelected={item.id === selectedModelId}
onSelect={handleSelectModel}
model={item}
/>
)}
contentContainerStyle={styles.listContent}
/>
)}
<View style={styles.footer}>
<TouchableOpacity
style={[styles.startButton, { backgroundColor: colors.primary }]}
onPress={handleStart}
>
<Text style={styles.startButtonText}>Konversation starten</Text>
<Ionicons name="arrow-forward" size={18} color="white" />
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 8,
},
backButton: {
marginRight: 16,
padding: 4,
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
subtitle: {
paddingHorizontal: 16,
marginBottom: 16,
fontSize: 16,
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 100,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
},
footer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
paddingHorizontal: 16,
paddingVertical: 16,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(0,0,0,0.1)',
backgroundColor: 'rgba(255,255,255,0.9)',
},
startButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
paddingVertical: 16,
},
startButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
marginRight: 8,
},
});

View file

@ -0,0 +1,720 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, Image, ScrollView, ActivityIndicator, Platform } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../context/AuthProvider';
import { useAppTheme } from '../theme/ThemeProvider';
import { supabase } from '../utils/supabase';
// Typendefinitionen für die Token-Nutzung
type ModelUsage = {
model_id: string;
model_name: string;
total_prompt_tokens: number;
total_completion_tokens: number;
total_tokens: number;
total_cost: number;
};
type UsageByPeriod = {
time_period: string;
total_tokens: number;
total_cost: number;
};
type UsageSummary = {
totalCost: number;
totalTokens: number;
modelCount: number;
periodCount: number;
};
export default function ProfileScreen() {
const { colors } = useTheme();
const { isDarkMode, toggleTheme } = useAppTheme();
const router = useRouter();
const { user, signOut } = useAuth();
// Zustandsvariablen für Token-Nutzungsdaten
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
const [periodUsage, setPeriodUsage] = useState<UsageByPeriod[]>([]);
const [summary, setSummary] = useState<UsageSummary | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [selectedPeriod, setSelectedPeriod] = useState<'day' | 'month' | 'year'>('month');
// Funktion zum Laden der Token-Nutzungsdaten
const loadUsageData = async () => {
if (!user) return;
setIsLoading(true);
try {
// Lade die Token-Nutzung nach Modell
const { data: modelData, error: modelError } = await supabase
.rpc('get_user_model_usage', { user_id: user.id });
if (modelError) {
console.error('Fehler beim Laden der Modellnutzung:', modelError);
} else if (modelData) {
setModelUsage(modelData as ModelUsage[]);
}
// Lade die Token-Nutzung nach Zeitraum
const { data: periodData, error: periodError } = await supabase
.rpc('get_user_usage_by_period', {
user_id: user.id,
period: selectedPeriod
});
if (periodError) {
console.error('Fehler beim Laden der Zeitraumnutzung:', periodError);
} else if (periodData) {
setPeriodUsage(periodData as UsageByPeriod[]);
}
// Berechne die Zusammenfassung
if (modelData) {
const totalCost = (modelData as ModelUsage[]).reduce((sum, model) => sum + model.total_cost, 0);
const totalTokens = (modelData as ModelUsage[]).reduce((sum, model) => sum + model.total_tokens, 0);
setSummary({
totalCost,
totalTokens,
modelCount: (modelData as ModelUsage[]).length,
periodCount: periodData ? (periodData as UsageByPeriod[]).length : 0
});
}
} catch (error) {
console.error('Fehler beim Laden der Nutzungsdaten:', error);
} finally {
setIsLoading(false);
}
};
// Lade die Nutzungsdaten beim ersten Rendern und wenn sich der Zeitraum ändert
useEffect(() => {
if (user) {
loadUsageData();
}
}, [user, selectedPeriod]);
// Formatierungsfunktionen
const formatCost = (cost: number): string => {
return `$${cost.toFixed(4)}`;
};
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
} else {
return tokens.toString();
}
};
const handlePeriodChange = (period: 'day' | 'month' | 'year') => {
setSelectedPeriod(period);
};
const handleSignOut = async () => {
Alert.alert(
'Abmelden',
'Möchtest du dich wirklich abmelden?',
[
{
text: 'Abbrechen',
style: 'cancel',
},
{
text: 'Abmelden',
style: 'destructive',
onPress: async () => {
await signOut();
router.replace('/auth/login');
},
},
],
);
};
return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>Profil</Text>
</View>
<View style={styles.profileSection}>
<View style={[styles.avatarContainer, { backgroundColor: colors.primary + '20' }]}>
<Text style={[styles.avatarText, { color: colors.primary }]}>
{user?.email?.charAt(0).toUpperCase() || 'U'}
</Text>
</View>
<View style={styles.userInfo}>
<Text style={[styles.userName, { color: colors.text }]}>
{user?.email?.split('@')[0] || 'Benutzer'}
</Text>
<Text style={[styles.userEmail, { color: colors.text + '80' }]}>
{user?.email || 'E-Mail nicht verfügbar'}
</Text>
</View>
</View>
{/* Token-Nutzungsstatistiken */}
<View style={styles.usageSection}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Token-Nutzung</Text>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
Lade Nutzungsdaten...
</Text>
</View>
) : summary ? (
<>
{/* Zusammenfassung der Nutzung */}
<View style={[styles.usageSummaryCard, {
backgroundColor: colors.card,
borderColor: colors.border,
shadowColor: isDarkMode ? undefined : '#000',
}]}>
<View style={styles.usageSummaryRow}>
<View style={styles.usageSummaryItem}>
<Text style={[styles.usageSummaryValue, { color: colors.primary }]}>
{formatTokens(summary.totalTokens)}
</Text>
<Text style={[styles.usageSummaryLabel, { color: colors.text + '80' }]}>
Tokens gesamt
</Text>
</View>
<View style={styles.usageSummaryDivider} />
<View style={styles.usageSummaryItem}>
<Text style={[styles.usageSummaryValue, { color: colors.primary }]}>
${summary.totalCost.toFixed(4)}
</Text>
<Text style={[styles.usageSummaryLabel, { color: colors.text + '80' }]}>
Kosten gesamt
</Text>
</View>
</View>
</View>
{/* Zeitraumauswahl */}
<View style={styles.periodSelector}>
<TouchableOpacity
style={[
styles.periodButton,
selectedPeriod === 'day' && {
backgroundColor: colors.primary + '20',
borderColor: colors.primary
}
]}
onPress={() => handlePeriodChange('day')}
>
<Text style={[
styles.periodButtonText,
{ color: colors.text },
selectedPeriod === 'day' && { color: colors.primary, fontWeight: '600' }
]}>
Tag
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.periodButton,
selectedPeriod === 'month' && {
backgroundColor: colors.primary + '20',
borderColor: colors.primary
}
]}
onPress={() => handlePeriodChange('month')}
>
<Text style={[
styles.periodButtonText,
{ color: colors.text },
selectedPeriod === 'month' && { color: colors.primary, fontWeight: '600' }
]}>
Monat
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.periodButton,
selectedPeriod === 'year' && {
backgroundColor: colors.primary + '20',
borderColor: colors.primary
}
]}
onPress={() => handlePeriodChange('year')}
>
<Text style={[
styles.periodButtonText,
{ color: colors.text },
selectedPeriod === 'year' && { color: colors.primary, fontWeight: '600' }
]}>
Jahr
</Text>
</TouchableOpacity>
</View>
{/* Modellnutzung */}
{modelUsage.length > 0 ? (
<View style={styles.modelUsageContainer}>
<Text style={[styles.usageSubtitle, { color: colors.text }]}>
Modelle
</Text>
{modelUsage.map((model, index) => (
<View
key={model.model_id}
style={[
styles.modelUsageItem,
{
backgroundColor: colors.card,
borderColor: colors.border
},
index === modelUsage.length - 1 && { marginBottom: 0 }
]}
>
<View style={styles.modelUsageHeader}>
<Text style={[styles.modelName, { color: colors.text }]}>
{model.model_name}
</Text>
<Text style={[styles.modelCost, { color: colors.primary }]}>
${model.total_cost.toFixed(4)}
</Text>
</View>
<View style={styles.modelUsageDetails}>
<View style={styles.tokenItem}>
<Text style={[styles.tokenCount, { color: colors.text }]}>
{formatTokens(model.total_prompt_tokens)}
</Text>
<Text style={[styles.tokenLabel, { color: colors.text + '70' }]}>
Prompt
</Text>
</View>
<View style={styles.tokenItem}>
<Text style={[styles.tokenCount, { color: colors.text }]}>
{formatTokens(model.total_completion_tokens)}
</Text>
<Text style={[styles.tokenLabel, { color: colors.text + '70' }]}>
Completion
</Text>
</View>
<View style={styles.tokenItem}>
<Text style={[styles.tokenCount, { color: colors.text }]}>
{formatTokens(model.total_tokens)}
</Text>
<Text style={[styles.tokenLabel, { color: colors.text + '70' }]}>
Gesamt
</Text>
</View>
</View>
</View>
))}
</View>
) : (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine Modellnutzung vorhanden
</Text>
</View>
)}
{/* Nutzung nach Zeitraum */}
{periodUsage.length > 0 ? (
<View style={styles.periodUsageContainer}>
<Text style={[styles.usageSubtitle, { color: colors.text }]}>
Nutzung nach {
selectedPeriod === 'day' ? 'Tagen' :
selectedPeriod === 'month' ? 'Monaten' : 'Jahren'
}
</Text>
{periodUsage.slice(0, 5).map((period, index) => (
<View
key={period.time_period}
style={[
styles.periodUsageItem,
{
backgroundColor: colors.card,
borderColor: colors.border
}
]}
>
<Text style={[styles.periodLabel, { color: colors.text }]}>
{period.time_period}
</Text>
<View style={styles.periodUsageContent}>
<Text style={[styles.periodTokens, { color: colors.text + 'CC' }]}>
{formatTokens(period.total_tokens)} Tokens
</Text>
<Text style={[styles.periodCost, { color: colors.primary }]}>
${period.total_cost.toFixed(4)}
</Text>
</View>
</View>
))}
{periodUsage.length > 5 && (
<TouchableOpacity style={[styles.viewMoreButton, { borderColor: colors.border }]}>
<Text style={[styles.viewMoreText, { color: colors.primary }]}>
Mehr anzeigen...
</Text>
</TouchableOpacity>
)}
</View>
) : (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine Nutzungsdaten für diesen Zeitraum
</Text>
</View>
)}
</>
) : (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine Nutzungsdaten verfügbar
</Text>
</View>
)}
</View>
<View style={styles.settingsSection}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Einstellungen</Text>
<TouchableOpacity
style={[styles.settingItem, { borderBottomColor: colors.border }]}
onPress={toggleTheme}
>
<View style={styles.settingIconContainer}>
<Ionicons
name={isDarkMode ? "moon" : "sunny"}
size={24}
color={colors.primary}
/>
</View>
<View style={styles.settingContent}>
<Text style={[styles.settingTitle, { color: colors.text }]}>
Erscheinungsbild
</Text>
<Text style={[styles.settingValue, { color: colors.text + '80' }]}>
{isDarkMode ? 'Dunkel' : 'Hell'}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.text + '60'} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.settingItem, { borderBottomColor: colors.border }]}
>
<View style={styles.settingIconContainer}>
<Ionicons name="notifications" size={24} color={colors.primary} />
</View>
<View style={styles.settingContent}>
<Text style={[styles.settingTitle, { color: colors.text }]}>
Benachrichtigungen
</Text>
<Text style={[styles.settingValue, { color: colors.text + '80' }]}>
Ein
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.text + '60'} />
</TouchableOpacity>
</View>
<View style={styles.accountSection}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Konto</Text>
<TouchableOpacity
style={[styles.settingItem, { borderBottomColor: colors.border }]}
>
<View style={styles.settingIconContainer}>
<Ionicons name="shield-checkmark" size={24} color={colors.primary} />
</View>
<View style={styles.settingContent}>
<Text style={[styles.settingTitle, { color: colors.text }]}>
Passwort ändern
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.text + '60'} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.settingItem, { borderBottomColor: colors.border }]}
onPress={handleSignOut}
>
<View style={styles.settingIconContainer}>
<Ionicons name="log-out" size={24} color="#FF3B30" />
</View>
<View style={styles.settingContent}>
<Text style={[styles.settingTitle, { color: '#FF3B30' }]}>
Abmelden
</Text>
</View>
</TouchableOpacity>
</View>
<View style={styles.appInfo}>
<Text style={[styles.versionText, { color: colors.text + '60' }]}>
Version 1.0.0
</Text>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
},
header: {
marginTop: 20,
marginBottom: 30,
},
title: {
fontSize: 28,
fontWeight: 'bold',
},
profileSection: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 30,
},
avatarContainer: {
width: 70,
height: 70,
borderRadius: 35,
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
avatarText: {
fontSize: 28,
fontWeight: 'bold',
},
userInfo: {
flex: 1,
},
userName: {
fontSize: 20,
fontWeight: '600',
marginBottom: 4,
},
userEmail: {
fontSize: 14,
},
// Nutzungsstatistik-Stile
usageSection: {
marginBottom: 30,
},
loadingContainer: {
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
loadingText: {
marginTop: 10,
fontSize: 14,
},
usageSummaryCard: {
borderRadius: 12,
padding: 20,
marginBottom: 16,
borderWidth: 1,
...Platform.select({
ios: {
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 2,
},
web: {
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
},
}),
},
usageSummaryRow: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
},
usageSummaryItem: {
alignItems: 'center',
flex: 1,
},
usageSummaryDivider: {
width: 1,
height: 40,
backgroundColor: '#E5E5EA',
marginHorizontal: 10,
},
usageSummaryValue: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 6,
},
usageSummaryLabel: {
fontSize: 14,
},
periodSelector: {
flexDirection: 'row',
marginBottom: 16,
justifyContent: 'center',
},
periodButton: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 20,
marginHorizontal: 4,
borderWidth: 1,
borderColor: '#E5E5EA',
},
periodButtonText: {
fontSize: 14,
},
modelUsageContainer: {
marginBottom: 20,
},
usageSubtitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 10,
},
modelUsageItem: {
borderRadius: 10,
padding: 12,
marginBottom: 10,
borderWidth: 1,
},
modelUsageHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
modelName: {
fontSize: 16,
fontWeight: '600',
},
modelCost: {
fontSize: 16,
fontWeight: '600',
},
modelUsageDetails: {
flexDirection: 'row',
justifyContent: 'space-between',
},
tokenItem: {
alignItems: 'center',
flex: 1,
},
tokenCount: {
fontSize: 14,
fontWeight: '500',
},
tokenLabel: {
fontSize: 12,
marginTop: 4,
},
periodUsageContainer: {
marginBottom: 20,
},
periodUsageItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 12,
borderRadius: 10,
marginBottom: 8,
borderWidth: 1,
},
periodLabel: {
fontSize: 15,
fontWeight: '500',
},
periodUsageContent: {
flexDirection: 'row',
alignItems: 'center',
},
periodTokens: {
fontSize: 14,
marginRight: 10,
},
periodCost: {
fontSize: 14,
fontWeight: '600',
},
viewMoreButton: {
padding: 10,
borderRadius: 8,
borderWidth: 1,
alignItems: 'center',
marginTop: 8,
},
viewMoreText: {
fontSize: 14,
fontWeight: '500',
},
emptyContainer: {
alignItems: 'center',
padding: 20,
},
emptyText: {
fontSize: 14,
},
// Bestehende Stile
settingsSection: {
marginBottom: 30,
},
accountSection: {
marginBottom: 30,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 16,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
},
settingIconContainer: {
width: 40,
alignItems: 'center',
marginRight: 12,
},
settingContent: {
flex: 1,
},
settingTitle: {
fontSize: 16,
fontWeight: '500',
},
settingValue: {
fontSize: 14,
marginTop: 2,
},
appInfo: {
alignItems: 'center',
marginTop: 16,
paddingBottom: 20,
},
versionText: {
fontSize: 14,
},
});

View file

@ -0,0 +1,634 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator, FlatList, Pressable, Platform } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useLocalSearchParams, useRouter, useFocusEffect } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../../../context/AuthProvider';
import { getSpace, getSpaceMembers, getUserRoleInSpace, Space, SpaceMember } from '../../../services/space';
import { getConversations, Conversation } from '../../../services/conversation';
export default function SpaceDetailScreen() {
const { colors } = useTheme();
const router = useRouter();
const { id } = useLocalSearchParams();
const { user } = useAuth();
const [space, setSpace] = useState<Space | null>(null);
const [members, setMembers] = useState<SpaceMember[]>([]);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [userRole, setUserRole] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'conversations' | 'members'>('conversations');
// Lade Space-Details, Mitglieder und Konversationen
const loadSpaceData = useCallback(async () => {
if (!user || !id) return;
setIsLoading(true);
try {
// Parallele Anfragen für bessere Performance
const [spaceData, membersData, roleData] = await Promise.all([
getSpace(id as string),
getSpaceMembers(id as string),
getUserRoleInSpace(id as string, user.id)
]);
if (spaceData) {
setSpace(spaceData);
// Lade Konversationen nur, wenn der Space gefunden wurde
const spaceConversations = await getConversations(user.id, spaceData.id);
setConversations(spaceConversations.filter(c => c.space_id === spaceData.id));
} else {
console.error('Space nicht gefunden');
Alert.alert('Fehler', 'Der Space konnte nicht gefunden werden.');
router.back();
return;
}
setMembers(membersData);
setUserRole(roleData);
} catch (error) {
console.error('Fehler beim Laden der Space-Daten:', error);
Alert.alert('Fehler', 'Die Space-Daten konnten nicht geladen werden.');
} finally {
setIsLoading(false);
}
}, [user, id, router]);
// Lade Daten beim ersten Rendern
useEffect(() => {
loadSpaceData();
}, [loadSpaceData]);
// Lade Daten erneut, wenn der Screen fokussiert wird
useFocusEffect(
useCallback(() => {
loadSpaceData();
return () => {};
}, [loadSpaceData])
);
// Zu einer Konversation navigieren
const handleConversationPress = (conversationId: string) => {
router.push(`/conversation/${conversationId}`);
};
// Neue Konversation in diesem Space starten
const handleNewConversation = () => {
if (!space) return;
router.push({
pathname: '/model-selection',
params: { spaceId: space.id }
});
};
// Neues Mitglied einladen
const handleInviteMember = () => {
if (!space) return;
router.push(`/spaces/${space.id}/invite`);
};
// Mitgliederliste rendern
const renderMemberItem = ({ item }: { item: SpaceMember }) => {
const isOwner = item.role === 'owner';
return (
<View style={[
styles.memberItem,
{
backgroundColor: colors.card,
borderColor: colors.border
}
]}>
<View style={[styles.memberAvatar, { backgroundColor: colors.primary }]}>
<Text style={styles.memberInitial}>
{item.user_id.substring(0, 1).toUpperCase()}
</Text>
</View>
<View style={styles.memberContent}>
<Text style={[styles.memberUserId, { color: colors.text }]}>
{item.user_id.substring(0, 8)}...
</Text>
<View style={styles.memberMeta}>
<View style={[
styles.roleBadge,
{
backgroundColor: isOwner
? colors.primary + '20'
: item.role === 'admin'
? colors.notification + '20'
: colors.border + '80'
}
]}>
<Text style={[
styles.roleBadgeText,
{
color: isOwner
? colors.primary
: item.role === 'admin'
? colors.notification
: colors.text + '80'
}
]}>
{isOwner ? 'Besitzer' :
item.role === 'admin' ? 'Admin' :
item.role === 'member' ? 'Mitglied' : 'Zuschauer'}
</Text>
</View>
<Text style={[styles.joinedDate, { color: colors.text + '70' }]}>
{item.joined_at
? `Beigetreten: ${new Date(item.joined_at).toLocaleDateString()}`
: item.invitation_status === 'pending'
? 'Einladung ausstehend'
: 'Status: ' + item.invitation_status}
</Text>
</View>
</View>
</View>
);
};
// Konversationsliste rendern
const renderConversationItem = ({ item }: { item: Conversation }) => {
return (
<Pressable
style={({ pressed, hovered }) => [
styles.conversationItem,
{
backgroundColor: colors.card,
borderColor: colors.border
},
hovered && { backgroundColor: colors.cardHover },
pressed && { opacity: 0.9 }
]}
onPress={() => handleConversationPress(item.id)}
>
{({ pressed, hovered }) => (
<>
<View style={styles.conversationIcon}>
<Ionicons name="chatbubble-ellipses-outline" size={24} color={colors.primary} />
</View>
<View style={styles.conversationContent}>
<Text style={[styles.conversationTitle, { color: colors.text }]} numberOfLines={1}>
{item.title || 'Unbenannte Konversation'}
</Text>
<Text style={[styles.conversationDate, { color: colors.text + '70' }]}>
{new Date(item.updated_at).toLocaleString()}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.text + '50'} />
</>
)}
</Pressable>
);
};
if (isLoading) {
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
Space wird geladen...
</Text>
</View>
</SafeAreaView>
);
}
if (!space) {
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={64} color={colors.text + '50'} />
<Text style={[styles.errorText, { color: colors.text }]}>
Space nicht gefunden
</Text>
<TouchableOpacity
style={[styles.backToSpacesButton, { backgroundColor: colors.primary }]}
onPress={() => router.push('/spaces')}
>
<Text style={styles.backToSpacesText}>Zurück zu Spaces</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: colors.text }]} numberOfLines={1}>
{space.name}
</Text>
</View>
{/* Space-Info Card */}
<View style={[styles.spaceInfoCard, {
backgroundColor: colors.card,
borderColor: colors.border
}]}>
<View style={styles.spaceInfoHeader}>
<View style={styles.spaceInfoTitleRow}>
<Ionicons name="people" size={24} color={colors.primary} style={styles.spaceInfoIcon} />
<View style={styles.spaceInfoTitleContainer}>
<Text style={[styles.spaceInfoTitle, { color: colors.text }]}>{space.name}</Text>
<Text style={[styles.spaceInfoSubtitle, { color: colors.text + '70' }]}>
{userRole === 'owner' ? 'Du bist Besitzer' :
userRole === 'admin' ? 'Du bist Admin' :
userRole === 'member' ? 'Du bist Mitglied' : 'Du bist Zuschauer'}
</Text>
</View>
</View>
{(userRole === 'owner' || userRole === 'admin') && (
<TouchableOpacity
style={[styles.editButton, { backgroundColor: colors.primary + '20' }]}
onPress={() => router.push(`/spaces/${space.id}/settings`)}
>
<Ionicons name="settings-outline" size={18} color={colors.primary} />
</TouchableOpacity>
)}
</View>
{space.description && (
<Text style={[styles.spaceInfoDescription, { color: colors.text + '90' }]}>
{space.description}
</Text>
)}
<View style={styles.spaceInfoDetails}>
<View style={styles.spaceInfoDetail}>
<Ionicons name="people-outline" size={16} color={colors.text + '70'} />
<Text style={[styles.spaceInfoDetailText, { color: colors.text + '70' }]}>
{members.length} Mitglieder
</Text>
</View>
<View style={styles.spaceInfoDetail}>
<Ionicons name="calendar-outline" size={16} color={colors.text + '70'} />
<Text style={[styles.spaceInfoDetailText, { color: colors.text + '70' }]}>
Erstellt: {new Date(space.created_at).toLocaleDateString()}
</Text>
</View>
</View>
</View>
{/* Tabs */}
<View style={[styles.tabContainer, { borderBottomColor: colors.border }]}>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === 'conversations' && { borderBottomColor: colors.primary, borderBottomWidth: 2 }
]}
onPress={() => setActiveTab('conversations')}
>
<Text style={[
styles.tabButtonText,
{ color: activeTab === 'conversations' ? colors.primary : colors.text + '70' }
]}>
Konversationen
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === 'members' && { borderBottomColor: colors.primary, borderBottomWidth: 2 }
]}
onPress={() => setActiveTab('members')}
>
<Text style={[
styles.tabButtonText,
{ color: activeTab === 'members' ? colors.primary : colors.text + '70' }
]}>
Mitglieder
</Text>
</TouchableOpacity>
</View>
{/* Tab-Inhalte */}
{activeTab === 'conversations' ? (
<View style={styles.tabContent}>
<TouchableOpacity
style={[styles.newButton, { backgroundColor: colors.primary }]}
onPress={handleNewConversation}
>
<Ionicons name="add" size={20} color="white" />
<Text style={styles.newButtonText}>Neue Konversation</Text>
</TouchableOpacity>
{conversations.length > 0 ? (
<FlatList
data={conversations}
keyExtractor={(item) => item.id}
renderItem={renderConversationItem}
contentContainerStyle={styles.listContent}
/>
) : (
<View style={styles.emptyContainer}>
<Ionicons name="chatbubbles-outline" size={60} color={colors.text + '30'} />
<Text style={[styles.emptyTitle, { color: colors.text }]}>
Keine Konversationen
</Text>
<Text style={[styles.emptyText, { color: colors.text + '70' }]}>
Starte eine neue Konversation in diesem Space
</Text>
</View>
)}
</View>
) : (
<View style={styles.tabContent}>
{(userRole === 'owner' || userRole === 'admin') && (
<TouchableOpacity
style={[styles.newButton, { backgroundColor: colors.primary }]}
onPress={handleInviteMember}
>
<Ionicons name="person-add" size={20} color="white" />
<Text style={styles.newButtonText}>Mitglied einladen</Text>
</TouchableOpacity>
)}
{members.length > 0 ? (
<FlatList
data={members}
keyExtractor={(item) => item.id}
renderItem={renderMemberItem}
contentContainerStyle={styles.listContent}
/>
) : (
<View style={styles.emptyContainer}>
<Ionicons name="people-outline" size={60} color={colors.text + '30'} />
<Text style={[styles.emptyTitle, { color: colors.text }]}>
Keine Mitglieder
</Text>
<Text style={[styles.emptyText, { color: colors.text + '70' }]}>
Lade Mitglieder zu diesem Space ein
</Text>
</View>
)}
</View>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
},
backButton: {
padding: 8,
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
marginLeft: 8,
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
marginTop: 16,
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
fontSize: 18,
fontWeight: '600',
marginVertical: 16,
},
backToSpacesButton: {
paddingVertical: 10,
paddingHorizontal: 20,
borderRadius: 8,
marginTop: 20,
},
backToSpacesText: {
color: 'white',
fontSize: 16,
fontWeight: '500',
},
spaceInfoCard: {
margin: 16,
padding: 16,
borderRadius: 12,
borderWidth: 1,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 3,
},
web: {
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
},
}),
},
spaceInfoHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
},
spaceInfoTitleRow: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
spaceInfoIcon: {
marginRight: 12,
},
spaceInfoTitleContainer: {
flex: 1,
},
spaceInfoTitle: {
fontSize: 18,
fontWeight: 'bold',
},
spaceInfoSubtitle: {
fontSize: 14,
marginTop: 2,
},
editButton: {
padding: 8,
borderRadius: 20,
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
},
spaceInfoDescription: {
fontSize: 15,
lineHeight: 22,
marginBottom: 16,
},
spaceInfoDetails: {
flexDirection: 'row',
flexWrap: 'wrap',
},
spaceInfoDetail: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 16,
marginBottom: 4,
},
spaceInfoDetailText: {
fontSize: 13,
marginLeft: 6,
},
tabContainer: {
flexDirection: 'row',
borderBottomWidth: 1,
marginBottom: 16,
},
tabButton: {
flex: 1,
paddingVertical: 12,
alignItems: 'center',
borderBottomWidth: 0,
},
tabButtonText: {
fontSize: 16,
fontWeight: '500',
},
tabContent: {
flex: 1,
paddingHorizontal: 16,
},
newButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 10,
marginBottom: 16,
},
newButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
marginLeft: 8,
},
listContent: {
paddingBottom: 20,
},
conversationItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 10,
borderWidth: 1,
marginBottom: 12,
},
conversationIcon: {
marginRight: 12,
},
conversationContent: {
flex: 1,
},
conversationTitle: {
fontSize: 16,
fontWeight: '500',
marginBottom: 4,
},
conversationDate: {
fontSize: 13,
},
memberItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 10,
borderWidth: 1,
marginBottom: 10,
},
memberAvatar: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
memberInitial: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
memberContent: {
flex: 1,
},
memberUserId: {
fontSize: 15,
fontWeight: '500',
marginBottom: 4,
},
memberMeta: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
},
roleBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
marginRight: 8,
},
roleBadgeText: {
fontSize: 12,
fontWeight: '500',
},
joinedDate: {
fontSize: 12,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: 40,
},
emptyTitle: {
fontSize: 18,
fontWeight: '600',
marginTop: 16,
},
emptyText: {
fontSize: 14,
textAlign: 'center',
marginTop: 8,
},
});

View file

@ -0,0 +1,503 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator, Pressable, Platform } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter, useFocusEffect } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../../context/AuthProvider';
import { getUserSpaces, Space, deleteSpace } from '../../services/space';
export default function SpaceListScreen() {
const { colors } = useTheme();
const router = useRouter();
const { user } = useAuth();
const [spaces, setSpaces] = useState<Space[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [expandedSpaceId, setExpandedSpaceId] = useState<string | null>(null);
// Laden der Spaces beim ersten Rendern und wenn der Screen fokussiert wird
const loadSpaces = useCallback(async () => {
if (!user) return;
setIsLoading(true);
try {
console.log("Lade Spaces für User:", user.id);
const userSpaces = await getUserSpaces(user.id);
console.log(`${userSpaces.length} Spaces geladen`);
setSpaces(userSpaces);
} catch (error) {
console.error('Fehler beim Laden der Spaces:', error);
Alert.alert('Fehler', 'Die Spaces konnten nicht geladen werden.');
} finally {
setIsLoading(false);
}
}, [user]);
// Lade Spaces beim ersten Rendern
useEffect(() => {
loadSpaces();
}, [loadSpaces]);
// Lade Spaces erneut, wenn der Screen fokussiert wird
useFocusEffect(
useCallback(() => {
loadSpaces();
return () => {};
}, [loadSpaces])
);
// Erstellen eines neuen Spaces
const handleCreateSpace = () => {
router.push('/spaces/new');
};
// Zu einem Space navigieren
const handleSpacePress = (id: string) => {
router.push(`/spaces/${id}`);
};
// Toggle-Funktion für das Optionsmenü
const toggleOptionsMenu = (id: string) => {
setExpandedSpaceId(expandedSpaceId === id ? null : id);
};
// Einen Space verlassen
const handleLeaveSpace = async (id: string) => {
Alert.alert(
"Space verlassen",
"Möchtest du diesen Space wirklich verlassen?",
[
{
text: "Abbrechen",
style: "cancel"
},
{
text: "Verlassen",
style: "destructive",
onPress: async () => {
// Diese Funktion würde einen Benutzer aus einem Space entfernen
// TODO: removeMember(id, user.id); implementieren
Alert.alert("Info", "Diese Funktion ist noch nicht implementiert.");
}
}
]
);
};
// Einen Space löschen (nur für Besitzer)
const handleDeleteSpace = async (id: string) => {
Alert.alert(
"Space löschen",
"Möchtest du diesen Space wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
[
{
text: "Abbrechen",
style: "cancel"
},
{
text: "Löschen",
style: "destructive",
onPress: async () => {
try {
const success = await deleteSpace(id);
if (success) {
// Aus der lokalen Liste entfernen
setSpaces(prev => prev.filter(space => space.id !== id));
Alert.alert("Erfolg", "Der Space wurde gelöscht.");
} else {
Alert.alert("Fehler", "Der Space konnte nicht gelöscht werden.");
}
} catch (error) {
console.error('Fehler beim Löschen des Spaces:', error);
Alert.alert("Fehler", "Der Space konnte nicht gelöscht werden.");
}
}
}
]
);
};
const renderSpaceItem = ({ item }: { item: Space }) => {
const showOptions = expandedSpaceId === item.id;
const isOwner = item.owner_id === user?.id;
return (
<View style={[
styles.spaceItemWrapper,
{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2
}
]}>
<Pressable
style={({ pressed, hovered }) => [
styles.spaceItem,
hovered && { backgroundColor: colors.cardHover },
pressed && { opacity: 0.9 }
]}
onPress={() => handleSpacePress(item.id)}
onLongPress={() => toggleOptionsMenu(item.id)}
>
{({ pressed, hovered }) => (
<>
<View style={styles.spaceContent}>
<View style={styles.spaceHeader}>
<View style={styles.titleRow}>
<Ionicons
name="people-outline"
size={18}
color={colors.primary}
style={styles.titleIcon}
/>
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
{item.name}
</Text>
{isOwner && (
<View style={[styles.ownerBadge, { backgroundColor: colors.primary + '20' }]}>
<Text style={[styles.ownerBadgeText, { color: colors.primary }]}>
Besitzer
</Text>
</View>
)}
</View>
</View>
{item.description && (
<Text
style={[styles.description, { color: colors.text + 'CC' }]}
numberOfLines={2}
>
{item.description}
</Text>
)}
<View style={styles.statsContainer}>
<Text style={[styles.timestamp, { color: colors.text + '80' }]}>
Erstellt: {new Date(item.created_at).toLocaleDateString()}
</Text>
</View>
</View>
<Pressable
style={({ pressed, hovered }) => [
styles.optionsButton,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.7 }
]}
onPress={() => toggleOptionsMenu(item.id)}
>
{({ pressed, hovered }) => (
<Ionicons
name="ellipsis-vertical"
size={20}
color={colors.text + '80'}
/>
)}
</Pressable>
</>
)}
</Pressable>
{showOptions && (
<View style={[styles.optionsContainer, {
backgroundColor: colors.card,
borderTopWidth: 1,
borderTopColor: colors.border
}]}>
{isOwner && (
<Pressable
style={({ pressed, hovered }) => [
styles.optionButton,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.8 }
]}
onPress={() => router.push(`/spaces/${item.id}/settings`)}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="settings-outline" size={18} color={colors.text} />
<Text style={[styles.optionText, { color: colors.text }]}>Einstellungen</Text>
</>
)}
</Pressable>
)}
<Pressable
style={({ pressed, hovered }) => [
styles.optionButton,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.8 }
]}
onPress={() => router.push(`/spaces/${item.id}/invite`)}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="person-add-outline" size={18} color={colors.text} />
<Text style={[styles.optionText, { color: colors.text }]}>Einladen</Text>
</>
)}
</Pressable>
{isOwner ? (
<Pressable
style={({ pressed, hovered }) => [
styles.optionButton,
hovered && { backgroundColor: colors.dangerHover },
pressed && { opacity: 0.8 }
]}
onPress={() => handleDeleteSpace(item.id)}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Löschen</Text>
</>
)}
</Pressable>
) : (
<Pressable
style={({ pressed, hovered }) => [
styles.optionButton,
hovered && { backgroundColor: colors.dangerHover },
pressed && { opacity: 0.8 }
]}
onPress={() => handleLeaveSpace(item.id)}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="exit-outline" size={18} color="#FF3B30" />
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Verlassen</Text>
</>
)}
</Pressable>
)}
</View>
)}
</View>
);
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: colors.text }]}>Spaces</Text>
</View>
<View style={styles.contentContainer}>
{/* Create new space button */}
<TouchableOpacity
style={[styles.createSpaceButton, { backgroundColor: colors.primary }]}
onPress={handleCreateSpace}
>
<Ionicons name="add" size={24} color="white" />
<Text style={styles.createSpaceText}>Neuen Space erstellen</Text>
</TouchableOpacity>
{/* Space list */}
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
Spaces werden geladen...
</Text>
</View>
) : spaces.length > 0 ? (
<FlatList
data={spaces}
keyExtractor={(item) => item.id}
renderItem={renderSpaceItem}
contentContainerStyle={styles.listContent}
/>
) : (
<View style={styles.emptyContainer}>
<Ionicons
name="people-outline"
size={64}
color={colors.text + '40'}
/>
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine Spaces gefunden
</Text>
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
Erstelle einen neuen Space oder frage nach einer Einladung
</Text>
</View>
)}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
},
backButton: {
padding: 8,
},
headerTitle: {
fontSize: 24,
fontWeight: 'bold',
marginLeft: 8,
},
contentContainer: {
flex: 1,
paddingHorizontal: 16,
},
createSpaceButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 12,
borderRadius: 12,
marginBottom: 16,
},
createSpaceText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
marginLeft: 8,
},
listContent: {
paddingBottom: 16,
},
spaceItemWrapper: {
borderRadius: 12,
overflow: 'hidden',
marginBottom: 16,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 3,
},
web: {
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
},
}),
},
spaceItem: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
},
spaceContent: {
flex: 1,
},
spaceHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
titleIcon: {
marginRight: 8,
},
title: {
fontSize: 18,
fontWeight: 'bold',
flex: 1,
},
ownerBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
marginLeft: 8,
},
ownerBadgeText: {
fontSize: 12,
fontWeight: '500',
},
description: {
fontSize: 14,
marginBottom: 12,
lineHeight: 20,
},
statsContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
},
timestamp: {
fontSize: 12,
},
optionsButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
optionsContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingBottom: 12,
paddingTop: 8,
},
optionButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
marginLeft: 8,
borderRadius: 6,
},
optionText: {
fontSize: 14,
marginLeft: 6,
fontWeight: '500',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
marginTop: 16,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
marginTop: 16,
},
emptySubtext: {
fontSize: 14,
textAlign: 'center',
marginTop: 8,
},
});

View file

@ -0,0 +1,214 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput, SafeAreaView, Alert, ActivityIndicator, ScrollView } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../../context/AuthProvider';
import { createSpace } from '../../services/space';
export default function NewSpaceScreen() {
const { colors } = useTheme();
const router = useRouter();
const { user } = useAuth();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isCreating, setIsCreating] = useState(false);
// Validieren der Eingaben
const isValid = name.trim().length > 0;
// Erstellen eines neuen Spaces
const handleCreateSpace = async () => {
if (!isValid || !user) return;
setIsCreating(true);
try {
const spaceId = await createSpace(user.id, name.trim(), description.trim() || undefined);
if (spaceId) {
// Navigation zum neuen Space
Alert.alert("Erfolg", "Space wurde erfolgreich erstellt.", [
{
text: "OK",
onPress: () => router.push(`/spaces/${spaceId}`)
}
]);
} else {
Alert.alert("Fehler", "Der Space konnte nicht erstellt werden.");
}
} catch (error) {
console.error('Fehler beim Erstellen des Spaces:', error);
Alert.alert("Fehler", "Der Space konnte nicht erstellt werden.");
} finally {
setIsCreating(false);
}
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: colors.text }]}>Neuen Space erstellen</Text>
</View>
<ScrollView style={styles.contentContainer} contentContainerStyle={styles.scrollContent}>
<View style={styles.formSection}>
<Text style={[styles.label, { color: colors.text }]}>Name *</Text>
<TextInput
style={[
styles.input,
{
backgroundColor: colors.card,
borderColor: colors.border,
color: colors.text
}
]}
placeholder="Name des Spaces"
placeholderTextColor={colors.text + '70'}
value={name}
onChangeText={setName}
maxLength={50}
/>
<Text style={[styles.label, { color: colors.text, marginTop: 20 }]}>Beschreibung</Text>
<TextInput
style={[
styles.textArea,
{
backgroundColor: colors.card,
borderColor: colors.border,
color: colors.text
}
]}
placeholder="Beschreibung des Spaces (optional)"
placeholderTextColor={colors.text + '70'}
value={description}
onChangeText={setDescription}
multiline
numberOfLines={4}
maxLength={500}
textAlignVertical="top"
/>
</View>
<View style={styles.infoSection}>
<View style={styles.infoItem}>
<Ionicons name="information-circle-outline" size={20} color={colors.text + '80'} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.text + '80' }]}>
Spaces sind Bereiche zum Organisieren von Konversationen und können mit anderen Nutzern geteilt werden.
</Text>
</View>
</View>
</ScrollView>
<View style={[styles.footer, { borderTopColor: colors.border }]}>
<TouchableOpacity
style={[
styles.createButton,
{
backgroundColor: isValid ? colors.primary : colors.primary + '50',
opacity: isCreating ? 0.7 : 1
}
]}
onPress={handleCreateSpace}
disabled={!isValid || isCreating}
>
{isCreating ? (
<ActivityIndicator size="small" color="white" />
) : (
<Text style={styles.createButtonText}>Space erstellen</Text>
)}
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
},
backButton: {
padding: 8,
},
headerTitle: {
fontSize: 20,
fontWeight: 'bold',
marginLeft: 8,
},
contentContainer: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 40,
},
formSection: {
marginBottom: 30,
},
label: {
fontSize: 16,
fontWeight: '500',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
},
textArea: {
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
minHeight: 120,
},
infoSection: {
marginBottom: 20,
},
infoItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 12,
},
infoIcon: {
marginRight: 8,
marginTop: 2,
},
infoText: {
fontSize: 14,
flex: 1,
lineHeight: 20,
},
footer: {
borderTopWidth: 1,
paddingHorizontal: 20,
paddingVertical: 16,
},
createButton: {
paddingVertical: 14,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
createButtonText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
});

View file

@ -0,0 +1,435 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
SafeAreaView,
Alert,
Modal,
ActivityIndicator
} from 'react-native';
import { useTheme, useFocusEffect } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../context/AuthProvider';
import { useAppTheme } from '../theme/ThemeProvider';
import TemplateCard from '../components/TemplateCard';
import TemplateForm from '../components/TemplateForm';
import CustomDrawer from '../components/CustomDrawer';
import {
Template,
getTemplates,
createTemplate,
updateTemplate,
deleteTemplate,
setDefaultTemplate
} from '../services/template';
export default function TemplatesScreen() {
const { colors } = useTheme();
const router = useRouter();
const { user } = useAuth();
const { isDarkMode } = useAppTheme();
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isFormModalVisible, setIsFormModalVisible] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
// Lade die Vorlagen
const loadTemplates = async () => {
if (!user) return;
setIsLoading(true);
try {
const userTemplates = await getTemplates(user.id);
setTemplates(userTemplates);
} catch (error) {
console.error('Fehler beim Laden der Vorlagen:', error);
Alert.alert('Fehler', 'Die Vorlagen konnten nicht geladen werden.');
} finally {
setIsLoading(false);
}
};
// Lade Vorlagen beim ersten Laden und wenn der Benutzer sich ändert
useEffect(() => {
loadTemplates();
}, [user]);
// Lade Vorlagen erneut, wenn der Screen fokussiert wird
useFocusEffect(
useCallback(() => {
if (user) loadTemplates();
return () => {};
}, [user])
);
// Öffne das Formular zum Erstellen einer neuen Vorlage
const handleCreateTemplate = () => {
setSelectedTemplate(null);
setIsFormModalVisible(true);
};
// Öffne das Formular zum Bearbeiten einer Vorlage
const handleEditTemplate = (id: string) => {
const template = templates.find(t => t.id === id);
if (template) {
setSelectedTemplate(template);
setIsFormModalVisible(true);
}
};
// Lösche eine Vorlage nach Bestätigung
const handleDeleteTemplate = (id: string) => {
Alert.alert(
"Vorlage löschen",
"Möchtest du diese Vorlage wirklich löschen?",
[
{
text: "Abbrechen",
style: "cancel"
},
{
text: "Löschen",
style: "destructive",
onPress: async () => {
try {
const success = await deleteTemplate(id);
if (success) {
setTemplates(prev => prev.filter(t => t.id !== id));
} else {
Alert.alert("Fehler", "Die Vorlage konnte nicht gelöscht werden.");
}
} catch (error) {
console.error('Fehler beim Löschen der Vorlage:', error);
Alert.alert("Fehler", "Die Vorlage konnte nicht gelöscht werden.");
}
}
}
]
);
};
// Setze eine Vorlage als Standard
const handleSetDefaultTemplate = async (id: string) => {
if (!user) return;
try {
const success = await setDefaultTemplate(id, user.id);
if (success) {
// Aktualisiere den lokalen Zustand, um die Änderungen anzuzeigen
setTemplates(prev =>
prev.map(t => ({
...t,
is_default: t.id === id
}))
);
} else {
Alert.alert("Fehler", "Die Standardvorlage konnte nicht gesetzt werden.");
}
} catch (error) {
console.error('Fehler beim Setzen der Standardvorlage:', error);
Alert.alert("Fehler", "Die Standardvorlage konnte nicht gesetzt werden.");
}
};
// Speichert eine neue oder bearbeitete Vorlage
const handleSubmitTemplate = async (templateData: Partial<Template>) => {
if (!user) return;
try {
// Prüfe, ob wir eine bestehende Vorlage bearbeiten oder eine neue erstellen
if (templateData.id) {
// Aktualisiere eine bestehende Vorlage
const success = await updateTemplate(templateData.id, {
name: templateData.name,
description: templateData.description,
system_prompt: templateData.system_prompt,
initial_question: templateData.initial_question,
color: templateData.color,
model_id: templateData.model_id,
document_mode: templateData.document_mode
});
if (success) {
setTemplates(prev =>
prev.map(t =>
t.id === templateData.id
? { ...t, ...templateData }
: t
)
);
} else {
Alert.alert("Fehler", "Die Vorlage konnte nicht aktualisiert werden.");
}
} else {
// Erstelle eine neue Vorlage
const newTemplate = await createTemplate({
user_id: user.id,
name: templateData.name!,
description: templateData.description,
system_prompt: templateData.system_prompt!,
initial_question: templateData.initial_question,
color: templateData.color!,
model_id: templateData.model_id,
is_default: false,
document_mode: templateData.document_mode || false,
});
if (newTemplate) {
setTemplates(prev => [...prev, newTemplate]);
} else {
Alert.alert("Fehler", "Die Vorlage konnte nicht erstellt werden.");
}
}
// Schließe das Modal
setIsFormModalVisible(false);
} catch (error) {
console.error('Fehler beim Speichern der Vorlage:', error);
Alert.alert("Fehler", "Die Vorlage konnte nicht gespeichert werden.");
}
};
// Starte einen neuen Chat mit einer Vorlage
const handleUseTemplate = (id: string) => {
const template = templates.find(t => t.id === id);
if (template) {
// Erstelle einen neuen Chat mit dieser Vorlage
router.push({
pathname: '/conversation/new',
params: {
templateId: template.id,
mode: 'template',
documentMode: template.document_mode ? 'true' : 'false'
}
});
}
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.mainLayout}>
{/* Drawer / Seitenmenü */}
{isDrawerOpen && (
<View style={styles.drawerContainer}>
<CustomDrawer
isVisible={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
/>
</View>
)}
{/* Hauptinhalt */}
<View style={styles.mainContainer}>
<View style={styles.contentContainer}>
<View style={styles.header}>
<TouchableOpacity
style={styles.menuButton}
onPress={() => setIsDrawerOpen(!isDrawerOpen)}
>
<Ionicons
name="menu-outline"
size={28}
color={colors.text}
/>
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>Vorlagen</Text>
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colors.primary }]}
onPress={handleCreateTemplate}
>
<Ionicons name="add" size={20} color="white" />
<Text style={styles.addButtonText}>Neue Vorlage</Text>
</TouchableOpacity>
</View>
{/* Beschreibung */}
<View style={styles.descriptionContainer}>
<Text style={[styles.description, { color: colors.text + 'CC' }]}>
Erstelle Vorlagen mit benutzerdefinierten System-Prompts für verschiedene KI-Verhaltensweisen.
</Text>
</View>
{/* Vorlagenliste */}
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
Vorlagen werden geladen...
</Text>
</View>
) : templates.length > 0 ? (
<FlatList
data={templates}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TemplateCard
id={item.id}
name={item.name}
description={item.description}
systemPrompt={item.system_prompt}
color={item.color}
isDefault={item.is_default}
onPress={handleUseTemplate}
onEdit={handleEditTemplate}
onDelete={handleDeleteTemplate}
onSetDefault={handleSetDefaultTemplate}
/>
)}
contentContainerStyle={styles.listContent}
/>
) : (
<View style={styles.emptyContainer}>
<Ionicons
name="document-text-outline"
size={64}
color={colors.text + '40'}
/>
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine Vorlagen vorhanden
</Text>
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
Erstelle deine erste Vorlage, um loszulegen
</Text>
</View>
)}
{/* Modal für das Erstellen/Bearbeiten von Vorlagen */}
<Modal
visible={isFormModalVisible}
animationType="slide"
transparent={false}
onRequestClose={() => setIsFormModalVisible(false)}
>
<SafeAreaView style={styles.modalContainer}>
<TemplateForm
initialData={selectedTemplate || undefined}
onSubmit={handleSubmitTemplate}
onCancel={() => setIsFormModalVisible(false)}
/>
</SafeAreaView>
</Modal>
</View>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
mainLayout: {
flex: 1,
flexDirection: 'row',
},
mainContainer: {
flex: 1,
alignItems: 'center',
},
drawerContainer: {
width: 260,
height: '100%',
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
zIndex: 10,
},
contentContainer: {
flex: 1,
maxWidth: 1200,
width: '100%',
},
header: {
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
maxWidth: 800,
width: '100%',
alignSelf: 'center',
},
menuButton: {
padding: 8,
marginRight: 8,
},
title: {
fontSize: 28,
fontWeight: 'bold',
flex: 1,
marginLeft: 8,
},
addButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
},
addButtonText: {
color: 'white',
fontWeight: '500',
marginLeft: 4,
},
descriptionContainer: {
paddingHorizontal: 20,
marginBottom: 16,
maxWidth: 800,
width: '100%',
alignSelf: 'center',
},
description: {
fontSize: 14,
},
listContent: {
padding: 16,
paddingHorizontal: 20,
paddingBottom: 120,
maxWidth: 800,
width: '100%',
alignSelf: 'center',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: 40,
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
emptyContainer: {
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
paddingTop: 40,
height: 300,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
marginTop: 16,
textAlign: 'center',
},
emptySubtext: {
fontSize: 14,
marginTop: 8,
textAlign: 'center',
},
modalContainer: {
flex: 1,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,12 @@
module.exports = function (api) {
api.cache(true);
const plugins = [];
plugins.push('react-native-reanimated/plugin');
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

View file

@ -0,0 +1,40 @@
{
"cesVersion": "2.14.1",
"projectName": "chat",
"packages": [
{
"name": "expo-router",
"type": "navigation",
"options": {
"type": "drawer + tabs"
}
},
{
"name": "nativewind",
"type": "styling"
},
{
"name": "supabase",
"type": "authentication"
}
],
"flags": {
"noGit": false,
"noInstall": false,
"overwrite": false,
"importAlias": true,
"packageManager": "npm",
"eas": true,
"publish": false
},
"packageManager": {
"type": "npm",
"version": "10.7.0"
},
"os": {
"type": "Darwin",
"platform": "darwin",
"arch": "arm64",
"kernelVersion": "24.1.0"
}
}

View file

@ -0,0 +1,22 @@
import { forwardRef } from 'react';
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
type ButtonProps = {
title: string;
} & TouchableOpacityProps;
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
return (
<TouchableOpacity
ref={ref}
{...touchableProps}
className={`${styles.button} ${touchableProps.className}`}>
<Text className={styles.buttonText}>{title}</Text>
</TouchableOpacity>
);
});
const styles = {
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
buttonText: 'text-white text-lg font-semibold text-center',
};

View file

@ -0,0 +1,93 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
type ChatHeaderProps = {
title?: string;
modelName: string;
conversationMode: string;
onBackPress?: () => void;
};
export default function ChatHeader({
title,
modelName,
conversationMode,
onBackPress
}: ChatHeaderProps) {
const { colors } = useTheme();
const router = useRouter();
const handleBackPress = () => {
if (onBackPress) {
onBackPress();
} else {
router.back();
}
};
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: colors.text }]}>
{title || 'Neuer Chat'}
</Text>
<View style={styles.subtitleContainer}>
<Text style={[styles.modelName, { color: colors.text + '80' }]}>
{modelName}
</Text>
<Text style={[styles.modeText, { color: colors.text + '80' }]}>
{conversationMode === 'frei' ? 'Freier Modus' :
conversationMode === 'geführt' ? 'Geführter Modus' : 'Vorlagen-Modus'}
</Text>
</View>
</View>
<TouchableOpacity style={styles.menuButton}>
<Ionicons name="ellipsis-vertical" size={24} color={colors.text} />
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(0,0,0,0.1)',
width: '100%',
maxWidth: 1200,
alignSelf: 'center',
},
backButton: {
padding: 4,
},
titleContainer: {
flex: 1,
},
title: {
fontSize: 18,
fontWeight: '600',
},
subtitleContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
modelName: {
fontSize: 13,
fontWeight: '500',
},
modeText: {
fontSize: 13,
marginLeft: 8,
},
menuButton: {
padding: 4,
},
});

View file

@ -0,0 +1,122 @@
import React from 'react';
import { View, TextInput, TouchableOpacity, Text, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import useChatInput from '../hooks/useChatInput';
import ModelDropdown from './ModelDropdown';
interface ChatInputProps {
onSend: (message: string) => void;
isLoading?: boolean;
placeholder?: string;
showModelSelection?: boolean;
selectedModelId?: string;
onSelectModel?: (id: string) => void;
showAttachments?: boolean;
showSearch?: boolean;
}
export default function ChatInput({
onSend,
isLoading = false,
placeholder = 'Nachricht eingeben...',
showModelSelection = false,
selectedModelId = '550e8400-e29b-41d4-a716-446655440000',
onSelectModel = () => {},
showAttachments = false,
showSearch = false,
}: ChatInputProps) {
const {
text,
setText,
handleSend,
canSend,
isDarkMode,
} = useChatInput({
onSend,
isLoading,
placeholder,
});
return (
<View className="w-full px-4">
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
{showModelSelection && (
<View className="flex-row justify-between items-center mb-3">
<Text className={`text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Modell:
</Text>
<ModelDropdown
selectedModelId={selectedModelId}
onSelectModel={onSelectModel}
/>
</View>
)}
<TextInput
className={`w-full min-h-[40px] text-base rounded-lg px-4 py-2 ${
isDarkMode
? 'text-white bg-[#1C1C1E]'
: 'text-black bg-gray-100'
}`}
placeholder={placeholder}
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
value={text}
onChangeText={setText}
multiline
maxLength={1000}
editable={!isLoading}
/>
<View className="flex-row justify-between items-center mt-4">
{(showAttachments || showSearch) && (
<View className="flex-row space-x-4">
{showAttachments && (
<TouchableOpacity className="flex-row items-center">
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
</TouchableOpacity>
)}
{showSearch && (
<TouchableOpacity className="flex-row items-center">
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
</TouchableOpacity>
)}
</View>
)}
<TouchableOpacity
className={`flex-row items-center px-3 py-2 rounded-full ${
canSend ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'
}`}
onPress={handleSend}
disabled={!canSend}
>
{isLoading ? (
<View className="flex-row items-center">
<View className="h-4 w-4 mr-1">
<ActivityIndicator size="small" color="#FFFFFF" />
</View>
<Text className="text-white">Wird gesendet...</Text>
</View>
) : (
<>
<Ionicons
name="send"
size={18}
color={canSend ? '#FFFFFF' : '#0A84FF'}
/>
<Text
className={`ml-1 ${canSend ? 'text-white' : 'text-[#0A84FF]'}`}
>
Senden
</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</View>
);
}

View file

@ -0,0 +1,338 @@
import React, { useState, forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
import { View, TextInput, TouchableOpacity, Text, ScrollView, StyleSheet, ActivityIndicator } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { useAppTheme } from '../theme/ThemeProvider';
import ModelDropdown from './ModelDropdown';
import { useRouter } from 'expo-router';
import { createConversation, addMessage } from '../services/conversation';
import { supabase } from '../utils/supabase';
import { useAuth } from '../context/AuthProvider';
import { Template, getTemplates } from '../services/template';
type ConversationStarterProps = {
onSend?: (message: string) => void;
placeholder?: string;
};
// Definiere die Ref-Methoden, die von außen aufgerufen werden können
export interface ConversationStarterRef {
focus: () => void;
}
const ConversationStarter = forwardRef<ConversationStarterRef, ConversationStarterProps>(({ onSend, placeholder = 'Ask anything' }, ref) => {
const [text, setText] = useState('');
const [selectedModelId, setSelectedModelId] = useState('550e8400-e29b-41d4-a716-446655440000'); // Default to Azure OpenAI GPT-O3-Mini
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { user } = useAuth();
const inputRef = useRef<TextInput>(null);
// Expose methods via ref
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
// Laden der Vorlagen beim ersten Rendern
useEffect(() => {
const loadTemplates = async () => {
if (!user) return;
setIsLoadingTemplates(true);
try {
const userTemplates = await getTemplates(user.id);
setTemplates(userTemplates);
} catch (error) {
console.error('Fehler beim Laden der Vorlagen:', error);
} finally {
setIsLoadingTemplates(false);
}
};
loadTemplates();
}, [user]);
const handleSend = async () => {
if (text.trim()) {
console.log("handleSend wird aufgerufen mit Text:", text.trim());
// Prüfen ob onSend-Prop existiert, aber für jetzt ignorieren
if (onSend && false) { // Deaktiviert: wir wollen immer unseren eigenen Code ausführen
console.log("onSend-Prop gefunden, rufe diese auf");
onSend(text.trim());
setText('');
return;
}
// Andernfalls starte eine neue Konversation
try {
setIsCreatingConversation(true);
console.log("Starte Erstellung einer neuen Konversation...");
// Verwende den Benutzer aus dem Auth-Kontext
if (!user) {
console.error('Kein Benutzer angemeldet');
router.replace('/auth/login');
return;
}
console.log(`Chat starten mit Modell-ID: ${selectedModelId}`);
const trimmedText = text.trim();
// WICHTIG: Setze Text zurück, bevor wir navigieren (UI-Block vermeiden)
setText('');
const mode = selectedTemplate ? 'template' : 'free';
const templateId = selectedTemplate?.id;
const modelToUse = selectedTemplate?.model_id || selectedModelId;
// Versuche zwei verschiedene Methoden, damit eine davon funktioniert
try {
// 1. Methode: Mit Route-Parametern im Objekt
console.log(`Methode 1: Mit Parametern im Objekt (${mode}, ${templateId || 'keine Vorlage'})`);
router.push({
pathname: '/conversation/new',
params: {
initialMessage: trimmedText,
modelId: modelToUse,
mode: mode,
...(templateId && { templateId })
}
});
} catch (routerError) {
console.error("Fehler bei Methode 1:", routerError);
// 2. Methode: Mit Query-String
console.log(`Methode 2: Mit Query-String`);
let queryParams = `?initialMessage=${encodeURIComponent(
trimmedText
)}&modelId=${encodeURIComponent(
modelToUse
)}&mode=${encodeURIComponent(mode)}`;
if (templateId) {
queryParams += `&templateId=${encodeURIComponent(templateId)}`;
}
router.push(`/conversation/new${queryParams}`);
}
// Zurücksetzen der ausgewählten Vorlage nach Navigation
setSelectedTemplate(null);
console.log(`Navigation zur Konversation ausgeführt`);
} catch (error) {
console.error('Fehler beim Erstellen der Konversation:', error);
alert(`Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
} finally {
setIsCreatingConversation(false);
}
} else {
console.log("Text ist leer, keine Aktion");
}
};
// Handler für das Auswählen einer Vorlage
const handleTemplateSelect = (template: Template) => {
// Wenn die Vorlage bereits ausgewählt ist, deaktivieren wir sie
if (selectedTemplate?.id === template.id) {
setSelectedTemplate(null);
// Zurücksetzen des Texts, wenn es die Vorschau war
if (text.startsWith('Frage: ')) {
setText('');
}
return;
}
// Sonst wählen wir die Vorlage aus
setSelectedTemplate(template);
setSelectedModelId(template.model_id || selectedModelId);
// Vorschau der initialen Frage im Eingabefeld anzeigen, wenn vorhanden
if (text.trim() === '') {
if (template.initial_question) {
setText(`Frage: ${template.initial_question}`);
}
}
};
return (
<View className="w-full px-4 max-w-3xl self-center">
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
<View className="flex-row justify-between items-center mb-3">
<Text className={`text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Modell:</Text>
<ModelDropdown
selectedModelId={selectedModelId}
onSelectModel={setSelectedModelId}
/>
</View>
<TextInput
ref={inputRef}
className={`w-full min-h-[40px] text-base ${isDarkMode ? 'text-white' : 'text-black'}`}
placeholder={placeholder}
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
value={text}
onChangeText={setText}
multiline
maxLength={1000}
/>
<View className="flex-row justify-between items-center mt-4">
<View className="flex-row space-x-4">
<TouchableOpacity className="flex-row items-center">
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center">
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
className={`flex-row items-center px-3 py-2 rounded-full ${text.trim() ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'}`}
onPress={() => {
console.log("Senden-Button gedrückt");
handleSend();
}}
disabled={!text.trim() || isCreatingConversation}
activeOpacity={0.7}
>
{isCreatingConversation ? (
<View className="flex-row items-center">
<View className="h-4 w-4 mr-1">
<ActivityIndicator size="small" color="#FFFFFF" />
</View>
<Text className="text-white">Wird erstellt...</Text>
</View>
) : (
<>
<Ionicons name="send" size={18} color={text.trim() ? '#FFFFFF' : '#0A84FF'} />
<Text className={`ml-1 ${text.trim() ? 'text-white' : 'text-[#0A84FF]'}`}>Senden</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
<View className="mt-4">
<View>
<Text className={`text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Vorlagen:
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="flex-row"
>
{isLoadingTemplates ? (
<View className={`flex-row items-center justify-center mr-2 px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}>
<ActivityIndicator size="small" color={isDarkMode ? '#FFFFFF' : '#0A84FF'} style={{marginRight: 6}} />
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Laden...
</Text>
</View>
) : templates.length > 0 ? (
templates.map((template) => (
<TouchableOpacity
key={template.id}
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
selectedTemplate?.id === template.id
? isDarkMode
? 'bg-[#0A84FF]80 border-[#0A84FF]'
: 'bg-[#0A84FF]40 border-[#0A84FF]'
: isDarkMode
? 'bg-[#2C2C2E] border-[#38383A]'
: 'bg-white border-[#E5E5EA]'
}`}
onPress={() => handleTemplateSelect(template)}
>
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: template.color || '#0A84FF',
marginRight: 6
}}
/>
<Text className={`text-sm ${
selectedTemplate?.id === template.id
? isDarkMode ? 'text-white font-medium' : 'text-[#0A84FF] font-medium'
: isDarkMode ? 'text-white' : 'text-black'
}`}>
{template.name}
</Text>
{selectedTemplate?.id === template.id && (
<Ionicons
name="checkmark-circle"
size={14}
color={isDarkMode ? '#FFFFFF' : '#0A84FF'}
style={{marginLeft: 4}}
/>
)}
</TouchableOpacity>
))
) : (
<TouchableOpacity
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}
onPress={() => router.push('/templates')}
>
<Ionicons
name="add-circle-outline"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={styles.chipIcon}
/>
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Vorlage erstellen
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
className={`flex-row items-center px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}
onPress={() => router.push('/templates')}
>
<Ionicons
name="settings-outline"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={styles.chipIcon}
/>
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Verwalten
</Text>
</TouchableOpacity>
</ScrollView>
</View>
</View>
</View>
);
});
// Styles für Elemente, die nicht mit NativeWind gestylt werden können
const styles = StyleSheet.create({
chipIcon: {
marginRight: 6,
},
});
export default ConversationStarter;

View file

@ -0,0 +1,9 @@
import { SafeAreaView } from 'react-native';
export const Container = ({ children }: { children: React.ReactNode }) => {
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
};
const styles = {
container: 'flex flex-1 m-6',
};

View file

@ -0,0 +1,442 @@
import React, { useState, forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
import { View, TextInput, TouchableOpacity, Text, ScrollView, StyleSheet, ActivityIndicator } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { useAppTheme } from '../theme/ThemeProvider';
import ModelDropdown from './ModelDropdown';
import { useRouter } from 'expo-router';
import { createConversation, addMessage } from '../services/conversation';
import { supabase } from '../utils/supabase';
import { useAuth } from '../context/AuthProvider';
import { Template, getTemplates } from '../services/template';
import { Space, getUserSpaces } from '../services/space';
type ConversationStarterProps = {
onSend?: (message: string) => void;
placeholder?: string;
spaceId?: string | null;
};
// Definiere die Ref-Methoden, die von außen aufgerufen werden können
export interface ConversationStarterRef {
focus: () => void;
}
const ConversationStarter = forwardRef<ConversationStarterRef, ConversationStarterProps>(({ onSend, placeholder = 'Was möchtest du wissen?', spaceId }, ref) => {
const [text, setText] = useState('');
const [selectedModelId, setSelectedModelId] = useState('550e8400-e29b-41d4-a716-446655440000'); // Default to Azure OpenAI GPT-O3-Mini
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [documentMode, setDocumentMode] = useState(false);
const [currentSpace, setCurrentSpace] = useState<Space | null>(null);
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { user } = useAuth();
const inputRef = useRef<TextInput>(null);
// Expose methods via ref
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
// Laden der Vorlagen und des aktuellen Space beim ersten Rendern
useEffect(() => {
const loadTemplates = async () => {
if (!user) return;
setIsLoadingTemplates(true);
try {
const userTemplates = await getTemplates(user.id);
setTemplates(userTemplates);
} catch (error) {
console.error('Fehler beim Laden der Vorlagen:', error);
} finally {
setIsLoadingTemplates(false);
}
};
loadTemplates();
}, [user]);
// Laden des Space-Namens, wenn eine spaceId vorhanden ist
useEffect(() => {
const loadSpace = async () => {
if (!spaceId) {
setCurrentSpace(null);
return;
}
try {
const space = await getSpace(spaceId);
setCurrentSpace(space);
} catch (error) {
console.error('Fehler beim Laden des Space:', error);
setCurrentSpace(null);
}
};
loadSpace();
}, [spaceId]);
// Tastatur-Event-Handler für Enter-Taste (besonders wichtig für Web)
const handleKeyPress = (e: any) => {
// Prüfen auf Enter ohne Shift für Submit
if (e.nativeEvent.key === 'Enter' && !e.nativeEvent.shiftKey) {
e.preventDefault(); // Verhindert Zeilenumbruch
handleSend();
}
};
const handleSend = async () => {
if (text.trim()) {
console.log("handleSend wird aufgerufen mit Text:", text.trim());
// Prüfen ob onSend-Prop existiert, aber für jetzt ignorieren
if (onSend && false) { // Deaktiviert: wir wollen immer unseren eigenen Code ausführen
console.log("onSend-Prop gefunden, rufe diese auf");
onSend(text.trim());
setText('');
return;
}
// Andernfalls starte eine neue Konversation
try {
setIsCreatingConversation(true);
console.log("Starte Erstellung einer neuen Konversation...");
// Verwende den Benutzer aus dem Auth-Kontext
if (!user) {
console.error('Kein Benutzer angemeldet');
router.replace('/auth/login');
return;
}
console.log(`Chat starten mit Modell-ID: ${selectedModelId}`);
const trimmedText = text.trim();
// WICHTIG: Setze Text zurück, bevor wir navigieren (UI-Block vermeiden)
setText('');
const mode = selectedTemplate ? 'template' : 'free';
const templateId = selectedTemplate?.id;
const modelToUse = selectedTemplate?.model_id || selectedModelId;
// Versuche zwei verschiedene Methoden, damit eine davon funktioniert
try {
// 1. Methode: Mit Route-Parametern im Objekt
console.log(`Methode 1: Mit Parametern im Objekt (${mode}, ${templateId || 'keine Vorlage'}, documentMode: ${documentMode}, spaceId: ${spaceId || 'keiner'})`);
router.push({
pathname: '/conversation/new',
params: {
initialMessage: trimmedText,
modelId: modelToUse,
mode: mode,
documentMode: documentMode ? 'true' : 'false',
...(templateId && { templateId }),
...(spaceId && { spaceId })
}
});
} catch (routerError) {
console.error("Fehler bei Methode 1:", routerError);
// 2. Methode: Mit Query-String
console.log(`Methode 2: Mit Query-String`);
let queryParams = `?initialMessage=${encodeURIComponent(
trimmedText
)}&modelId=${encodeURIComponent(
modelToUse
)}&mode=${encodeURIComponent(mode)}&documentMode=${encodeURIComponent(documentMode ? 'true' : 'false')}`;
if (templateId) {
queryParams += `&templateId=${encodeURIComponent(templateId)}`;
}
if (spaceId) {
queryParams += `&spaceId=${encodeURIComponent(spaceId)}`;
}
router.push(`/conversation/new${queryParams}`);
}
// Zurücksetzen der ausgewählten Vorlage nach Navigation
setSelectedTemplate(null);
console.log(`Navigation zur Konversation ausgeführt`);
} catch (error) {
console.error('Fehler beim Erstellen der Konversation:', error);
alert(`Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
} finally {
setIsCreatingConversation(false);
}
} else {
console.log("Text ist leer, keine Aktion");
}
};
// Handler für das Auswählen einer Vorlage
const handleTemplateSelect = (template: Template) => {
// Wenn die Vorlage bereits ausgewählt ist, deaktivieren wir sie
if (selectedTemplate?.id === template.id) {
setSelectedTemplate(null);
// Auch den Dokumentmodus zurücksetzen
setDocumentMode(false);
} else {
// Sonst wählen wir die Vorlage aus
setSelectedTemplate(template);
// Modell automatisch auswählen, wenn die Vorlage eines definiert
if (template.model_id) {
setSelectedModelId(template.model_id);
}
// Dokumentmodus automatisch übernehmen, wenn die Vorlage ihn aktiviert hat
setDocumentMode(template.document_mode || false);
console.log(`Template ${template.name} ausgewählt, Dokumentmodus: ${template.document_mode}`);
}
// Nach der Auswahl/Abwahl einer Vorlage das Eingabefeld fokussieren
// Kurze Verzögerung, um UI-Updates abzuschließen
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 50);
};
return (
<View className="w-full px-4 max-w-3xl self-center">
{/* Container für den Titel mit fester Höhe - verhindert Layout-Verschiebung */}
<View className="h-7 flex-row items-center">
{selectedTemplate && (
<Text
className={`text-base font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}
numberOfLines={1}
>
{selectedTemplate.name}
</Text>
)}
{currentSpace && (
<View className="flex-row items-center ml-auto">
<Ionicons
name="folder-open"
size={16}
color={colors.primary}
style={{ marginRight: 4 }}
/>
<Text
className={`text-sm font-medium`}
style={{ color: colors.primary }}
numberOfLines={1}
>
Space: {currentSpace.name}
</Text>
</View>
)}
</View>
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
<TextInput
ref={inputRef}
className={`w-full min-h-[40px] text-base ${isDarkMode ? 'text-white' : 'text-black'}`}
placeholder={selectedTemplate?.initial_question ? selectedTemplate.initial_question : placeholder}
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
value={text}
onChangeText={setText}
multiline
maxLength={1000}
onSubmitEditing={() => {
if (text.trim()) {
handleSend();
}
}}
blurOnSubmit={false}
onKeyPress={handleKeyPress}
/>
<View className="flex-row justify-between items-center mt-4">
<View className="flex-row flex-wrap">
<TouchableOpacity
className={`flex-row items-center py-1 px-2 rounded-md mr-4 ${
documentMode
? 'bg-[#0A84FF]40 border border-[#0A84FF]'
: isDarkMode
? 'bg-[#2C2C2E] border border-[#38383A]'
: 'bg-[#F2F2F7] border border-[#E5E5EA]'
}`}
onPress={() => setDocumentMode(!documentMode)}
>
<Ionicons
name={documentMode ? "document" : "document-outline"}
size={18}
color={documentMode ? '#0A84FF' : (isDarkMode ? '#FFFFFF' : '#000000')}
/>
<Text className={`ml-1 ${documentMode ? 'text-[#0A84FF] font-medium' : (isDarkMode ? 'text-white' : 'text-black')}`}>
Dokument
</Text>
{documentMode && (
<Ionicons name="checkmark-circle" size={14} color="#0A84FF" style={{marginLeft: 4}} />
)}
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center mr-4">
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center mr-4">
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
</TouchableOpacity>
<View className="flex-row items-center">
<ModelDropdown
selectedModelId={selectedModelId}
onSelectModel={setSelectedModelId}
/>
</View>
</View>
<TouchableOpacity
className={`flex-row items-center px-3 py-2 rounded-full ${text.trim() ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'}`}
onPress={() => {
console.log("Senden-Button gedrückt");
handleSend();
}}
disabled={!text.trim() || isCreatingConversation}
activeOpacity={0.7}
>
{isCreatingConversation ? (
<View className="flex-row items-center">
<View className="h-4 w-4 mr-1">
<ActivityIndicator size="small" color="#FFFFFF" />
</View>
<Text className="text-white">Wird erstellt...</Text>
</View>
) : (
<>
<Ionicons name="send" size={18} color={text.trim() ? '#FFFFFF' : '#0A84FF'} />
<Text className={`ml-1 ${text.trim() ? 'text-white' : 'text-[#0A84FF]'}`}>Senden</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
<View className="mt-4">
<View>
<Text className={`text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
Vorlagen:
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="flex-row"
>
{isLoadingTemplates ? (
<View className={`flex-row items-center justify-center mr-2 px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}>
<ActivityIndicator size="small" color={isDarkMode ? '#FFFFFF' : '#0A84FF'} style={{marginRight: 6}} />
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Laden...
</Text>
</View>
) : templates.length > 0 ? (
templates.map((template) => (
<TouchableOpacity
key={template.id}
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
selectedTemplate?.id === template.id
? isDarkMode
? 'bg-[#0A84FF]80 border-[#0A84FF]'
: 'bg-[#0A84FF]40 border-[#0A84FF]'
: isDarkMode
? 'bg-[#2C2C2E] border-[#38383A]'
: 'bg-white border-[#E5E5EA]'
}`}
onPress={() => handleTemplateSelect(template)}
>
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: template.color || '#0A84FF',
marginRight: 6
}}
/>
<Text className={`text-sm ${
selectedTemplate?.id === template.id
? isDarkMode ? 'text-white font-medium' : 'text-[#0A84FF] font-medium'
: isDarkMode ? 'text-white' : 'text-black'
}`}>
{template.name}
</Text>
{selectedTemplate?.id === template.id && (
<Ionicons
name="checkmark-circle"
size={14}
color={isDarkMode ? '#FFFFFF' : '#0A84FF'}
style={{marginLeft: 4}}
/>
)}
</TouchableOpacity>
))
) : (
<TouchableOpacity
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}
onPress={() => router.push('/templates')}
>
<Ionicons
name="add-circle-outline"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={styles.chipIcon}
/>
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Vorlage erstellen
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
className={`flex-row items-center px-3 py-1 rounded-full border ${
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
}`}
onPress={() => router.push('/templates')}
>
<Ionicons
name="settings-outline"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={styles.chipIcon}
/>
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
Verwalten
</Text>
</TouchableOpacity>
</ScrollView>
</View>
</View>
</View>
);
});
// Styles für Elemente, die nicht mit NativeWind gestylt werden können
const styles = StyleSheet.create({
chipIcon: {
marginRight: 6,
},
});
export default ConversationStarter;

View file

@ -0,0 +1,490 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Pressable,
ScrollView,
Dimensions,
StatusBar,
ActivityIndicator,
SafeAreaView,
Platform
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '@react-navigation/native';
import { useAppTheme } from '../theme/ThemeProvider';
import { useAuth } from '../context/AuthProvider';
import { getConversations } from '../services/conversation';
const DRAWER_WIDTH = 260; // Breite des Drawer-Menüs
interface CustomDrawerProps {
isVisible: boolean;
focusInputOnHomeNavigate?: () => void;
onClose?: () => void;
}
export default function CustomDrawer({
isVisible,
focusInputOnHomeNavigate,
onClose
}: CustomDrawerProps) {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { user, signOut } = useAuth();
const [recentChats, setRecentChats] = useState<{id: string, title: string}[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Lade die letzten Chats
useEffect(() => {
const loadRecentChats = async () => {
if (!user || !isVisible) return;
setIsLoading(true);
try {
const conversations = await getConversations(user.id);
// Nimm nur die letzten 10 Konversationen
const recentOnes = conversations.slice(0, 10).map(conv => ({
id: conv.id,
title: conv.title || 'Unbenannte Konversation'
}));
setRecentChats(recentOnes);
} catch (error) {
console.error('Fehler beim Laden der letzten Chats:', error);
} finally {
setIsLoading(false);
}
};
if (isVisible) {
loadRecentChats();
}
}, [user, isVisible]);
// Navigation zum Home-Screen (mit Input-Fokus)
const navigateToHome = () => {
router.push('/');
if (focusInputOnHomeNavigate) {
// Verzögerung, um sicherzustellen, dass der Bildschirm geladen ist
setTimeout(() => {
focusInputOnHomeNavigate();
}, 100);
}
};
// Navigation zu einer Konversation
const navigateToConversation = (id: string) => {
router.push(`/conversation/${id}`);
};
// Navigation zur Archiv-Seite
const navigateToArchive = () => {
router.push('/archive');
};
// Navigation zur Vorlagen-Seite
const navigateToTemplates = () => {
router.push('/templates');
};
// Navigation zur Dokumente-Seite
const navigateToDocuments = () => {
router.push('/documents');
};
// Navigation zur Profilseite
const navigateToProfile = () => {
router.push('/profile');
};
// Styling für das Drawer-Menü
const bgColor = isDarkMode ? '#1C1C1E' : '#FFFFFF';
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
const separatorColor = isDarkMode ? '#38383A' : '#E5E5EA';
const activeColor = '#0A84FF';
// Wenn der Drawer nicht sichtbar sein soll, gib nichts zurück
if (!isVisible) {
return null;
}
return (
<SafeAreaView
style={[
styles.drawer,
{
backgroundColor: bgColor,
width: DRAWER_WIDTH,
borderRightWidth: 1,
borderRightColor: separatorColor
}
]}
>
{/* Drawer-Header */}
<View style={styles.drawerHeader}>
<Text style={[styles.drawerTitle, { color: textColor }]}>
Menu
</Text>
<Pressable
onPress={onClose}
style={({ pressed, hovered }) => [
styles.iconButton,
hovered && { backgroundColor: colors.menuItemHover }
]}
>
{({ pressed, hovered }) => (
<Ionicons
name="close"
size={24}
color={textColor}
style={{ opacity: pressed ? 0.7 : 1 }}
/>
)}
</Pressable>
</View>
{/* Hauptaktionen */}
<View style={styles.mainActions}>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{ backgroundColor: activeColor },
pressed && { opacity: 0.85 }
]}
onPress={navigateToHome}
>
<Ionicons name="add-circle-outline" size={20} color="white" />
<Text style={styles.mainActionText}>Neuen Chat starten</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={navigateToArchive}
>
<Ionicons name="archive-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Archiv ansehen</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={() => router.push('/conversations')}
>
<Ionicons name="chatbubbles-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Konversationen</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={navigateToDocuments}
>
<Ionicons name="document-text-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Dokumente ansehen</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={navigateToTemplates}
>
<Ionicons name="file-tray-full-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Vorlagen verwalten</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={() => router.push('/spaces')}
>
<Ionicons name="people-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Spaces</Text>
</Pressable>
<Pressable
style={({ pressed, hovered }) => [
styles.mainActionButton,
{
backgroundColor: hovered ? colors.buttonHover : 'transparent',
borderWidth: 1,
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
marginTop: 8
},
pressed && { opacity: 0.8 }
]}
onPress={navigateToProfile}
>
<Ionicons name="person-outline" size={20} color={textColor} />
<Text style={[styles.mainActionText, { color: textColor }]}>Profil & Statistiken</Text>
</Pressable>
</View>
{/* Trennlinie */}
<View style={[styles.separator, { backgroundColor: separatorColor }]} />
{/* Letzte Chats */}
<View style={styles.recentChatsHeader}>
<Text style={[styles.recentChatsTitle, { color: textColor }]}>
Letzte Chats
</Text>
</View>
{/* Liste der letzten Chats */}
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={activeColor} />
<Text style={[styles.loadingText, { color: textColor + '80' }]}>
Chats werden geladen...
</Text>
</View>
) : (
<ScrollView style={styles.recentChatsList}>
{recentChats.length > 0 ? (
recentChats.map((chat) => (
<Pressable
key={chat.id}
style={({ pressed, hovered }) => [
styles.chatItem,
hovered && { backgroundColor: colors.menuItemHover },
pressed && { opacity: 0.7 }
]}
onPress={() => navigateToConversation(chat.id)}
>
{({ pressed, hovered }) => (
<>
<Ionicons
name="chatbubble-ellipses-outline"
size={20}
color={textColor + '99'}
style={styles.chatIcon}
/>
<Text
style={[
styles.chatTitle,
{ color: textColor }
]}
numberOfLines={1}
ellipsizeMode="tail"
>
{chat.title}
</Text>
</>
)}
</Pressable>
))
) : (
<View style={styles.emptyChatsContainer}>
<Text style={[styles.emptyChatsText, { color: textColor + '80' }]}>
Keine Chats vorhanden
</Text>
</View>
)}
</ScrollView>
)}
{/* Benutzerinformationen und Logout-Button */}
<View style={styles.userSection}>
<View style={styles.separator} />
<View style={styles.userContainer}>
{user && (
<View style={styles.userInfo}>
<Ionicons name="person-circle-outline" size={24} color={textColor} />
<Text style={[styles.userEmail, { color: textColor }]}>
{user.email}
</Text>
</View>
)}
<Pressable
style={({ pressed, hovered }) => [
styles.logoutButton,
{ borderColor: separatorColor },
hovered && { backgroundColor: colors.dangerHover },
pressed && { opacity: 0.8 }
]}
onPress={() => {
signOut().then(() => router.replace('/auth/login'));
}}
>
{({ pressed, hovered }) => (
<>
<Ionicons name="log-out-outline" size={20} color={textColor} />
<Text style={[styles.logoutText, { color: textColor }]}>
Abmelden
</Text>
</>
)}
</Pressable>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
drawer: {
height: '100%',
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 2, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
drawerHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
},
drawerTitle: {
fontSize: 22,
fontWeight: 'bold',
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
mainActions: {
paddingHorizontal: 20,
paddingVertical: 16,
},
mainActionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
borderRadius: 8,
},
mainActionText: {
color: 'white',
fontSize: 16,
fontWeight: '500',
marginLeft: 8,
},
separator: {
height: 1,
marginVertical: 8,
},
recentChatsHeader: {
paddingHorizontal: 20,
paddingVertical: 12,
},
recentChatsTitle: {
fontSize: 16,
fontWeight: '600',
},
recentChatsList: {
flex: 1,
},
chatItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
marginHorizontal: 8,
},
chatIcon: {
marginRight: 12,
},
chatTitle: {
fontSize: 15,
flex: 1,
},
loadingContainer: {
padding: 20,
alignItems: 'center',
},
loadingText: {
marginTop: 8,
fontSize: 14,
},
emptyChatsContainer: {
padding: 20,
alignItems: 'center',
},
emptyChatsText: {
fontSize: 14,
},
userSection: {
paddingHorizontal: 20,
paddingVertical: 16,
marginTop: 'auto',
},
userContainer: {
marginTop: 10,
},
userInfo: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
userEmail: {
fontSize: 14,
marginLeft: 8,
},
logoutButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
borderRadius: 8,
borderWidth: 1,
marginTop: 4,
},
logoutText: {
fontSize: 15,
fontWeight: '500',
marginLeft: 8,
},
});

View file

@ -0,0 +1,385 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
StyleSheet,
TouchableOpacity,
ScrollView,
ActivityIndicator,
useWindowDimensions,
Platform,
Alert,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { Document } from '../services/document';
import Markdown from 'react-native-markdown-display';
interface DocumentPanelProps {
document: Document | null;
isLoading?: boolean;
versionCount: number;
onSave?: (content: string) => void;
onShowVersions?: () => void;
onNextVersion?: () => void;
onPreviousVersion?: () => void;
onDeleteVersion?: (document: Document) => void;
}
// Hilfsfunktion, um zu prüfen, ob der Dark Mode aktiv ist
const isDarkMode = (colors: any) => {
return colors.background === '#000' ||
colors.background === '#121212' ||
colors.background.includes('rgba(0,0,0') ||
colors.text === '#fff' ||
colors.text === '#ffffff';
};
export default function DocumentPanel({
document,
isLoading = false,
versionCount,
onSave,
onShowVersions,
onNextVersion,
onPreviousVersion,
onDeleteVersion
}: DocumentPanelProps) {
const { colors } = useTheme();
const [content, setContent] = useState<string>(document?.content || '');
const [editing, setEditing] = useState<boolean>(false);
const { width } = useWindowDimensions();
// Aktualisiere den Content, wenn sich das Dokument ändert
useEffect(() => {
if (document) {
setContent(document.content);
}
}, [document]);
const handleEdit = () => {
setEditing(true);
};
const handleCancel = () => {
setContent(document?.content || '');
setEditing(false);
};
const handleSave = () => {
if (onSave) {
onSave(content);
}
setEditing(false);
};
const renderVersionControls = () => {
// Aktuelle Version und Versionszählung
const currentVersion = document?.version || 1;
const hasMultipleVersions = versionCount > 1;
const canGoBack = currentVersion > 1;
const canGoForward = currentVersion < versionCount;
return (
<View style={styles.versionControls}>
{/* Pfeil zurück */}
<TouchableOpacity
style={[
styles.versionArrow,
!canGoBack && styles.versionArrowDisabled
]}
onPress={canGoBack ? onPreviousVersion : undefined}
disabled={!canGoBack}
>
<Ionicons
name="chevron-back"
size={16}
color={canGoBack ? '#666' : '#CCC'}
/>
</TouchableOpacity>
{/* Version Badge */}
<TouchableOpacity
style={styles.versionBadge}
onPress={onShowVersions}
>
<Text style={styles.versionText}>v{currentVersion}</Text>
{hasMultipleVersions && (
<Text style={styles.versionCount}>{versionCount}</Text>
)}
</TouchableOpacity>
{/* Pfeil vorwärts */}
<TouchableOpacity
style={[
styles.versionArrow,
!canGoForward && styles.versionArrowDisabled
]}
onPress={canGoForward ? onNextVersion : undefined}
disabled={!canGoForward}
>
<Ionicons
name="chevron-forward"
size={16}
color={canGoForward ? '#666' : '#CCC'}
/>
</TouchableOpacity>
</View>
);
};
if (isLoading) {
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text }]}>
Dokument wird geladen...
</Text>
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
{renderVersionControls()}
<View style={styles.actions}>
{editing ? (
<>
<TouchableOpacity style={styles.actionButton} onPress={handleCancel}>
<Ionicons name="close" size={22} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={handleSave}>
<Ionicons name="checkmark" size={22} color={colors.primary} />
</TouchableOpacity>
</>
) : (
<>
{document && onDeleteVersion && versionCount > 1 && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => {
if (document) {
console.log('Löschen-Button in DocumentPanel gedrückt für Version:', document.version);
Alert.alert(
"Version löschen",
`Möchtest du die Version ${document.version} wirklich löschen?`,
[
{
text: "Abbrechen",
style: "cancel"
},
{
text: "Löschen",
style: "destructive",
onPress: () => {
console.log('Löschvorgang bestätigt für Version:', document.version);
if (onDeleteVersion) {
onDeleteVersion(document);
} else {
console.error('onDeleteVersion Funktion ist nicht definiert');
}
}
}
]
);
}
}}
>
<Ionicons name="trash-outline" size={22} color="#ff3b30" />
<Text style={{fontSize: 10, color: '#ff3b30', marginLeft: 4}}>Löschen</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.actionButton} onPress={handleEdit}>
<Ionicons name="create-outline" size={22} color={colors.text} />
</TouchableOpacity>
</>
)}
</View>
</View>
{editing ? (
<TextInput
style={[
styles.editor,
{
color: colors.text,
backgroundColor: colors.background,
borderColor: colors.border
}
]}
multiline
value={content}
onChangeText={setContent}
autoFocus
textAlignVertical="top"
/>
) : (
<ScrollView style={styles.contentContainer}>
{document?.content ? (
<Markdown
style={{
body: {
color: colors.text,
fontSize: 15,
lineHeight: 22
},
heading1: {
color: colors.text,
borderBottomWidth: 1,
borderBottomColor: colors.border,
paddingBottom: 8,
marginBottom: 12
},
heading2: {
color: colors.text,
borderBottomWidth: 1,
borderBottomColor: colors.border + '60',
paddingBottom: 6,
marginBottom: 10
},
heading3: { color: colors.text },
heading4: { color: colors.text },
heading5: { color: colors.text },
heading6: { color: colors.text },
paragraph: {
color: colors.text,
marginBottom: 12
},
list_item: { color: colors.text },
blockquote: {
backgroundColor: colors.card,
borderLeftColor: colors.primary,
borderLeftWidth: 4,
paddingHorizontal: 12,
paddingVertical: 8,
marginVertical: 8
},
code_block: {
backgroundColor: isDarkMode(colors) ? '#1E1E1E' : '#F5F5F5',
padding: 10,
borderRadius: 6
},
link: { color: colors.primary }
}}
>
{document.content}
</Markdown>
) : (
<Text style={[styles.content, { color: colors.text }]}>
Noch kein Dokument erstellt.
</Text>
)}
</ScrollView>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
borderRadius: 12,
overflow: 'hidden',
marginHorizontal: 16,
marginBottom: 16,
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
},
title: {
fontSize: 18,
fontWeight: '600',
flex: 1,
},
versionControls: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 8,
},
versionArrow: {
width: 24,
height: 24,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 12,
backgroundColor: 'rgba(0,0,0,0.05)',
},
versionArrowDisabled: {
backgroundColor: 'rgba(0,0,0,0.02)',
},
versionBadge: {
backgroundColor: 'rgba(0,0,0,0.1)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 4,
},
versionText: {
fontSize: 12,
fontWeight: '500',
color: '#666',
},
versionCount: {
fontSize: 10,
fontWeight: '700',
color: '#fff',
backgroundColor: '#666',
width: 16,
height: 16,
borderRadius: 8,
textAlign: 'center',
lineHeight: 16,
marginLeft: 4,
},
actions: {
flexDirection: 'row',
},
actionButton: {
padding: 6,
marginLeft: 8,
},
contentContainer: {
padding: 16,
flex: 1,
paddingBottom: 60, // Extra padding für besseres Scrollen
},
content: {
fontSize: 15,
lineHeight: 22,
},
editor: {
flex: 1,
padding: 16,
fontSize: 15,
lineHeight: 24,
borderWidth: 1,
borderRadius: 8,
margin: 8,
textAlignVertical: 'top',
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
loadingText: {
marginTop: 10,
fontSize: 14,
},
});

View file

@ -0,0 +1,243 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Modal,
SafeAreaView,
Alert,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { Document } from '../services/document';
interface DocumentVersionsProps {
isVisible: boolean;
documents: Document[];
onClose: () => void;
onSelectVersion: (document: Document) => void;
onDeleteVersion?: (document: Document) => void;
}
export default function DocumentVersions({
isVisible,
documents,
onClose,
onSelectVersion,
onDeleteVersion
}: DocumentVersionsProps) {
const { colors } = useTheme();
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const renderVersionItem = (document: Document, isLatest: boolean) => {
// Löschen nur anzeigen, wenn es mehr als eine Version gibt und es nicht die neueste ist
// oder wenn es die einzige Version ist (nur zur Konsistenz)
const canDelete = documents.length > 1 || !isLatest;
return (
<View
key={document.id}
style={[
styles.versionItem,
{ borderBottomColor: colors.border }
]}
>
<TouchableOpacity
style={{flex: 1}}
activeOpacity={0.6}
onPress={() => {
console.log('Version auswählen:', document.id);
onSelectVersion(document);
}}
>
<View style={styles.versionHeader}>
<View style={styles.versionBadge}>
<Text style={styles.versionNumber}>v{document.version}</Text>
</View>
{isLatest && (
<View style={[styles.latestBadge, { backgroundColor: colors.primary }]}>
<Text style={styles.latestText}>Aktuell</Text>
</View>
)}
</View>
<Text style={[styles.versionDate, { color: colors.text + '99' }]}>
{formatDate(document.created_at)}
</Text>
<Text
style={[styles.versionPreview, { color: colors.text }]}
numberOfLines={2}
>
{document.content.substring(0, 150)}
{document.content.length > 150 ? '...' : ''}
</Text>
</TouchableOpacity>
{/* Löschen-Button außerhalb der Touchable-Fläche für den Artikel */}
{canDelete && onDeleteVersion && (
<TouchableOpacity
style={[styles.deleteSeparateButton, { backgroundColor: colors.card }]}
activeOpacity={0.7}
onPress={() => {
console.log("Löschen-Button separat wurde gedrückt für:", document.id);
// Direkter Aufruf für Testzwecke
if (onDeleteVersion) {
console.log("Rufe onDeleteVersion direkt auf für Dokument ID:", document.id);
onDeleteVersion(document);
// Schließe das Modal nach einer kurzen Verzögerung
setTimeout(() => {
onClose();
}, 100);
} else {
console.error("onDeleteVersion ist nicht definiert!");
}
}}
>
<Ionicons name="trash" size={18} color="red" />
<Text style={styles.deleteButtonText}>Löschen</Text>
</TouchableOpacity>
)}
</View>
);
};
return (
<Modal
visible={isVisible}
animationType="slide"
transparent={false}
>
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.header}>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>Dokumentversionen</Text>
</View>
<ScrollView style={styles.versionsList}>
{documents.map((document, index) => renderVersionItem(document, index === 0))}
{documents.length === 0 && (
<View style={styles.emptyState}>
<Ionicons name="document-outline" size={48} color={colors.text + '40'} />
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Keine Dokumentversionen verfügbar
</Text>
</View>
)}
</ScrollView>
</SafeAreaView>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.1)',
},
closeButton: {
padding: 8,
marginRight: 8,
},
title: {
fontSize: 18,
fontWeight: '600',
},
versionsList: {
flex: 1,
},
versionItem: {
padding: 16,
borderBottomWidth: 1,
},
versionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
versionBadge: {
backgroundColor: '#e0e0e0',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
},
versionNumber: {
fontSize: 12,
fontWeight: '600',
color: '#333',
},
latestBadge: {
marginLeft: 8,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
},
latestText: {
fontSize: 12,
fontWeight: '600',
color: 'white',
},
deleteSeparateButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
marginHorizontal: 8,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#ff3b30',
},
deleteButtonText: {
color: 'red',
marginLeft: 6,
fontSize: 14,
fontWeight: '500',
},
versionDate: {
fontSize: 13,
marginBottom: 8,
},
versionPreview: {
fontSize: 14,
lineHeight: 20,
},
emptyState: {
alignItems: 'center',
justifyContent: 'center',
padding: 40,
},
emptyText: {
marginTop: 16,
fontSize: 16,
textAlign: 'center',
},
});

View file

@ -0,0 +1,29 @@
import { Text, View } from 'react-native';
export const EditScreenInfo = ({ path }: { path: string }) => {
const title = 'Open up the code for this screen:';
const description =
'Change any of the text, save the file, and your app will automatically update.';
return (
<View>
<View className={styles.getStartedContainer}>
<Text className={styles.getStartedText}>{title}</Text>
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
<Text>{path}</Text>
</View>
<Text className={styles.getStartedText}>{description}</Text>
</View>
</View>
);
};
const styles = {
codeHighlightContainer: `rounded-md px-1`,
getStartedContainer: `items-center mx-12`,
getStartedText: `text-lg leading-6 text-center`,
helpContainer: `items-center mx-5 mt-4`,
helpLink: `py-4`,
helpLinkText: `text-center`,
homeScreenFilename: `my-2`,
};

View file

@ -0,0 +1,108 @@
import React, { useState, forwardRef, useImperativeHandle, useRef } from 'react';
import { View, TextInput, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@react-navigation/native';
type MessageInputProps = {
onSend: (message: string) => void;
isLoading?: boolean;
};
// Öffentliche Methoden über Ref
export interface MessageInputRef {
focus: () => void;
}
const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function MessageInput({ onSend, isLoading = false }, ref) {
const [message, setMessage] = useState('');
const { colors } = useTheme();
const inputRef = useRef<TextInput>(null);
// Stellt die focus-Methode über ref zur Verfügung
useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current) {
inputRef.current.focus();
}
}
}));
const handleSend = () => {
if (message.trim() && !isLoading) {
onSend(message.trim());
setMessage('');
}
};
// Tastatur-Event-Handler für Enter-Taste (besonders wichtig für Web)
const handleKeyPress = (e: any) => {
// Prüfen auf Enter ohne Shift für Submit
if (e.nativeEvent.key === 'Enter' && !e.nativeEvent.shiftKey) {
e.preventDefault(); // Verhindert Zeilenumbruch
handleSend();
}
};
return (
<View style={[styles.container, { backgroundColor: colors.card }]}>
<TextInput
ref={inputRef}
style={[styles.input, { color: colors.text, backgroundColor: colors.background }]}
placeholder="Nachricht eingeben..."
placeholderTextColor={colors.text + '80'}
value={message}
onChangeText={setMessage}
multiline
maxLength={1000}
editable={!isLoading}
onSubmitEditing={handleSend}
blurOnSubmit={false}
onKeyPress={handleKeyPress}
/>
<TouchableOpacity
style={[styles.sendButton, { backgroundColor: colors.primary }]}
onPress={handleSend}
disabled={!message.trim() || isLoading}
>
{isLoading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Ionicons name="send" size={20} color="#fff" />
)}
</TouchableOpacity>
</View>
);
}
);
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(0,0,0,0.1)',
width: '100%',
maxWidth: 1200,
alignSelf: 'center',
},
input: {
flex: 1,
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
maxHeight: 120,
marginRight: 8,
},
sendButton: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
});
export default MessageInput;

View file

@ -0,0 +1,97 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useTheme } from '@react-navigation/native';
import SkeletonLoader from './SkeletonLoader';
import TypingIndicator from './TypingIndicator';
type MessageProps = {
text: string;
sender: 'user' | 'ai';
timestamp: Date;
isLoading?: boolean;
};
export default function MessageItem({
text,
sender,
timestamp,
isLoading = false
}: MessageProps) {
const { colors } = useTheme();
const isUser = sender === 'user';
return (
<View style={[
styles.container,
isUser ? styles.userContainer : styles.aiContainer,
{ backgroundColor: isUser ? colors.primary : colors.card }
]}>
{isLoading && sender === 'ai' ? (
// Zeige Skeleton oder TypingIndicator wenn geladen wird
<>
<SkeletonLoader
lines={4}
style={styles.skeletonContainer}
/>
<TypingIndicator
dotColor={colors.text + '80'}
style={styles.typingIndicator}
/>
</>
) : (
// Zeige die eigentliche Nachricht
<Text style={[
styles.messageText,
{ color: isUser ? '#fff' : colors.text }
]}>
{text}
</Text>
)}
<Text style={[
styles.timestamp,
{ color: isUser ? 'rgba(255,255,255,0.7)' : colors.text + '80' }
]}>
{timestamp.getHours().toString().padStart(2, '0')}:{timestamp.getMinutes().toString().padStart(2, '0')}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 12,
borderRadius: 16,
marginVertical: 4,
marginHorizontal: 12,
},
userContainer: {
maxWidth: '80%',
alignSelf: 'flex-start',
borderBottomLeftRadius: 4,
},
aiContainer: {
width: '95%',
alignSelf: 'flex-end',
borderBottomRightRadius: 4,
},
messageText: {
fontSize: 16,
lineHeight: 22,
},
timestamp: {
fontSize: 12,
marginTop: 4,
alignSelf: 'flex-end',
},
skeletonContainer: {
padding: 0,
margin: 0,
opacity: 0.8,
},
typingIndicator: {
marginLeft: -5,
marginTop: 5,
}
});

View file

@ -0,0 +1,64 @@
import React from 'react';
import { FlatList, StyleSheet, View } from 'react-native';
import MessageItem from './MessageItem';
type Message = {
id: string;
text: string;
sender: 'user' | 'ai';
timestamp: Date;
isLoading?: boolean;
};
type MessageListProps = {
messages: Message[];
isLoading?: boolean;
};
export default function MessageList({ messages, isLoading = false }: MessageListProps) {
const renderMessageItem = ({ item, index }: { item: Message, index: number }) => {
// Wenn die Nachricht die letzte ist und vom KI-Assistenten stammt,
// zeigen wir den Lade-Indikator an, wenn isLoading=true ist
const isLastMessage = index === messages.length - 1;
const isLastAIMessage = isLastMessage && item.sender === 'ai';
const shouldShowLoading = isLoading && isLastAIMessage;
return (
<MessageItem
text={item.text}
sender={item.sender}
timestamp={item.timestamp}
isLoading={shouldShowLoading || item.isLoading}
/>
);
};
return (
<FlatList
data={messages}
keyExtractor={(item) => item.id}
renderItem={renderMessageItem}
style={styles.container}
contentContainerStyle={styles.contentContainer}
inverted={false}
showsVerticalScrollIndicator={false}
ListFooterComponent={<View style={styles.footer} />}
/>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
width: '100%',
maxWidth: 800,
alignSelf: 'center',
},
contentContainer: {
paddingVertical: 16,
paddingHorizontal: 16,
},
footer: {
height: 20,
},
});

View file

@ -0,0 +1,122 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { Ionicons } from '@expo/vector-icons';
import { Model } from '../types';
type ModelCardProps = {
id: string;
name: string;
description: string;
deployment?: string;
isSelected?: boolean;
onSelect: (id: string) => void;
model?: Model; // Optionales komplettes Model-Objekt
};
export default function ModelCard({
id,
name,
description,
isSelected = false,
onSelect,
model
}: ModelCardProps) {
const { colors } = useTheme();
const deployment = model?.parameters?.deployment;
return (
<TouchableOpacity
style={[
styles.container,
{
backgroundColor: colors.card,
borderColor: isSelected ? colors.primary : 'transparent',
}
]}
onPress={() => onSelect(id)}
>
<View style={styles.iconContainer}>
<Ionicons
name="chatbubble-ellipses-outline"
size={24}
color={colors.primary}
/>
</View>
<View style={styles.contentContainer}>
<Text style={[styles.name, { color: colors.text }]}>{name}</Text>
<Text
style={[styles.description, { color: colors.text + '80' }]}
numberOfLines={2}
>
{description}
</Text>
{deployment && (
<Text
style={[styles.deployment, { color: colors.primary + 'CC' }]}
numberOfLines={1}
>
{deployment}
</Text>
)}
</View>
{isSelected && (
<View style={[styles.checkmark, { backgroundColor: colors.primary }]}>
<Ionicons name="checkmark" size={16} color="#fff" />
</View>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 12,
marginBottom: 12,
borderWidth: 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.05)',
},
contentContainer: {
flex: 1,
marginLeft: 16,
},
name: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
description: {
fontSize: 14,
lineHeight: 20,
marginBottom: 4,
},
deployment: {
fontSize: 12,
fontWeight: '500',
},
checkmark: {
width: 24,
height: 24,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
});

View file

@ -0,0 +1,161 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, Modal, FlatList, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useAppTheme } from '../theme/ThemeProvider';
import { Model } from '../types';
import { availableModels } from '../config/azure';
import { getModels } from '../services/modelService';
// Verwende Modelle aus der Konfiguration
const FALLBACK_MODELS: Model[] = availableModels;
type ModelDropdownProps = {
selectedModelId: string;
onSelectModel: (id: string) => void;
};
export default function ModelDropdown({ selectedModelId, onSelectModel }: ModelDropdownProps) {
const { isDarkMode } = useAppTheme();
const [isModalVisible, setIsModalVisible] = useState(false);
const [models, setModels] = useState<Model[]>(FALLBACK_MODELS);
const [loading, setLoading] = useState(false);
// Lade die Modelle vom ModelService
useEffect(() => {
const fetchModels = async () => {
try {
setLoading(true);
const modelsList = await getModels();
setModels(modelsList);
} catch (err) {
console.error('Fehler beim Laden der Modelle:', err);
setModels(FALLBACK_MODELS);
} finally {
setLoading(false);
}
};
fetchModels();
}, []);
const selectedModel = models.find(model => model.id === selectedModelId) || models[0];
return (
<View>
<TouchableOpacity
onPress={() => setIsModalVisible(true)}
className={`flex-row items-center rounded-lg px-2 py-1 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-gray-100'}`}
>
<Text className={`text-sm font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
{selectedModel.name}
</Text>
<Ionicons
name="chevron-down"
size={16}
color={isDarkMode ? '#FFFFFF' : '#000000'}
style={{ marginLeft: 4 }}
/>
</TouchableOpacity>
<Modal
visible={isModalVisible}
transparent={true}
animationType="fade"
onRequestClose={() => setIsModalVisible(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setIsModalVisible(false)}
>
<View
className={`mx-4 rounded-xl p-4 ${isDarkMode ? 'bg-[#1C1C1E]' : 'bg-white'}`}
style={styles.modalContent}
>
<Text className={`text-lg font-bold mb-4 ${isDarkMode ? 'text-white' : 'text-black'}`}>
Modell auswählen
</Text>
{loading ? (
<View className="py-4 items-center">
<Text className={`${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Modelle werden geladen...
</Text>
</View>
) : (
<FlatList
data={models}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
className={`flex-row items-center p-3 mb-2 rounded-lg ${
item.id === selectedModelId
? isDarkMode ? 'bg-blue-900/30' : 'bg-blue-100'
: isDarkMode ? 'bg-[#2C2C2E]' : 'bg-gray-100'
}`}
onPress={() => {
onSelectModel(item.id);
setIsModalVisible(false);
}}
>
<View className="w-8 h-8 rounded-full bg-blue-500/20 items-center justify-center mr-3">
<Ionicons
name="chatbubble-ellipses-outline"
size={16}
color="#0A84FF"
/>
</View>
<View className="flex-1">
<Text className={`font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
{item.name}
</Text>
<Text
className={`text-xs mt-1 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
numberOfLines={1}
>
{item.description}
</Text>
{item.parameters?.deployment && (
<Text
className={`text-xs mt-1 ${isDarkMode ? 'text-blue-400' : 'text-blue-500'}`}
numberOfLines={1}
>
{item.parameters.deployment}
</Text>
)}
</View>
{item.id === selectedModelId && (
<View className="w-6 h-6 rounded-full bg-blue-500 items-center justify-center">
<Ionicons name="checkmark" size={14} color="#FFFFFF" />
</View>
)}
</TouchableOpacity>
)}
/>
)}
<TouchableOpacity
className={`mt-3 py-3 rounded-lg items-center ${isDarkMode ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]'}`}
onPress={() => setIsModalVisible(false)}
>
<Text className="text-white font-medium">Schließen</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
},
modalContent: {
maxHeight: '80%',
},
});

View file

@ -0,0 +1,46 @@
import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@react-navigation/native';
type NewChatButtonProps = {
onPress: () => void;
};
export default function NewChatButton({ onPress }: NewChatButtonProps) {
const { colors } = useTheme();
return (
<TouchableOpacity
style={[styles.button, { backgroundColor: colors.primary }]}
onPress={onPress}
>
<Ionicons name="add" size={24} color="#fff" style={styles.icon} />
<Text style={styles.text}>Neuer Chat</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 30,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
icon: {
marginRight: 8,
},
text: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});

View file

@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react';
import { View, Animated, Easing, StyleSheet } from 'react-native';
import { useTheme } from '@react-navigation/native';
type SkeletonLoaderProps = {
lines?: number;
animated?: boolean;
style?: any;
};
export default function SkeletonLoader({
lines = 3,
animated = true,
style
}: SkeletonLoaderProps) {
const { colors } = useTheme();
const [fadeAnim] = useState(new Animated.Value(0.3));
useEffect(() => {
if (animated) {
Animated.loop(
Animated.sequence([
Animated.timing(fadeAnim, {
toValue: 0.8,
duration: 800,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0.3,
duration: 800,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
])
).start();
}
}, [fadeAnim, animated]);
// Erstelle verschiedene Längen für die Zeilen
const getRandomWidth = (index: number) => {
// Erste und letzte Zeile sind kürzer
if (index === 0) return { width: '70%' };
if (index === lines - 1) return { width: '40%' };
// Zufällige Breite für die Zeilen dazwischen
const widths = ['85%', '90%', '75%', '95%'];
return { width: widths[index % widths.length] };
};
return (
<View style={[styles.container, style]}>
{Array.from({ length: lines }).map((_, index) => (
<Animated.View
key={index}
style={[
styles.line,
getRandomWidth(index),
{
backgroundColor: colors.text + '20',
opacity: fadeAnim,
marginBottom: index === lines - 1 ? 0 : 8
},
]}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
maxWidth: '80%',
alignSelf: 'flex-start',
},
line: {
height: 15,
borderRadius: 4,
},
});

View file

@ -0,0 +1,180 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@react-navigation/native';
import { useAppTheme } from '../theme/ThemeProvider';
// Typ für die Template-Props
interface TemplateCardProps {
id: string;
name: string;
description?: string | null;
systemPrompt: string;
color?: string;
isDefault?: boolean;
onPress: (id: string) => void;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
onSetDefault?: (id: string) => void;
}
export default function TemplateCard({
id,
name,
description,
systemPrompt,
color = '#0A84FF',
isDefault = false,
onPress,
onEdit,
onDelete,
onSetDefault
}: TemplateCardProps) {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const backgroundColor = isDarkMode ? '#2C2C2E' : '#FFFFFF';
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
const secondaryTextColor = isDarkMode ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)';
// Kürze den System-Prompt für die Anzeige
const truncatedPrompt = systemPrompt.length > 80
? systemPrompt.substring(0, 80) + '...'
: systemPrompt;
return (
<TouchableOpacity
style={[
styles.container,
{ backgroundColor },
isDefault && styles.defaultContainer
]}
onPress={() => onPress(id)}
>
{/* Farbiger Indikator am linken Rand */}
<View style={[styles.colorIndicator, { backgroundColor: color }]} />
<View style={styles.content}>
<View style={styles.header}>
<Text style={[styles.name, { color: textColor }]}>{name}</Text>
{isDefault && (
<View style={styles.defaultBadge}>
<Text style={styles.defaultText}>Standard</Text>
</View>
)}
</View>
{description && (
<Text
style={[styles.description, { color: secondaryTextColor }]}
numberOfLines={2}
>
{description}
</Text>
)}
<Text
style={[styles.prompt, { color: secondaryTextColor }]}
numberOfLines={2}
>
{truncatedPrompt}
</Text>
</View>
{/* Aktionen */}
<View style={styles.actions}>
{onSetDefault && !isDefault && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onSetDefault(id)}
>
<Ionicons name="star-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
</TouchableOpacity>
)}
{onEdit && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onEdit(id)}
>
<Ionicons name="pencil" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onDelete(id)}
>
<Ionicons name="trash-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
borderRadius: 12,
marginBottom: 12,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
defaultContainer: {
borderWidth: 1,
borderColor: '#0A84FF',
},
colorIndicator: {
width: 8,
alignSelf: 'stretch',
},
content: {
flex: 1,
padding: 16,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
name: {
fontSize: 16,
fontWeight: '600',
flex: 1,
},
defaultBadge: {
backgroundColor: '#0A84FF',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
marginLeft: 8,
},
defaultText: {
color: 'white',
fontSize: 10,
fontWeight: '600',
},
description: {
fontSize: 14,
marginBottom: 8,
},
prompt: {
fontSize: 12,
fontStyle: 'italic',
},
actions: {
padding: 8,
justifyContent: 'center',
},
actionButton: {
padding: 8,
},
});

View file

@ -0,0 +1,417 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
Alert
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '@react-navigation/native';
import { useAppTheme } from '../theme/ThemeProvider';
import ModelDropdown from './ModelDropdown';
import { Template } from '../services/template';
// Verfügbare Farben für Vorlagen
const TEMPLATE_COLORS = [
'#0A84FF', // Blau
'#32D74B', // Grün
'#FF375F', // Rot
'#FF9F0A', // Orange
'#5E5CE6', // Lila
'#BF5AF2', // Pink
'#64D2FF', // Hellblau
'#30D158', // Grün
'#FF453A', // Rot
];
interface TemplateFormProps {
initialData?: Partial<Template>;
onSubmit: (data: Partial<Template>) => void;
onCancel: () => void;
}
export default function TemplateForm({
initialData,
onSubmit,
onCancel
}: TemplateFormProps) {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
// Form state
const [name, setName] = useState(initialData?.name || '');
const [description, setDescription] = useState(initialData?.description || '');
const [systemPrompt, setSystemPrompt] = useState(initialData?.system_prompt || '');
const [initialQuestion, setInitialQuestion] = useState(initialData?.initial_question || '');
const [selectedColor, setSelectedColor] = useState(initialData?.color || TEMPLATE_COLORS[0]);
const [selectedModelId, setSelectedModelId] = useState(initialData?.model_id || '');
const [documentMode, setDocumentMode] = useState(initialData?.document_mode || false);
// Validierung
const [errors, setErrors] = useState<{
name?: string;
systemPrompt?: string;
}>({});
// Helpers
const isEditMode = !!initialData?.id;
const bgColor = isDarkMode ? '#1C1C1E' : '#FFFFFF';
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
const placeholderColor = isDarkMode ? '#8E8E93' : '#C7C7CC';
const borderColor = isDarkMode ? '#38383A' : '#E5E5EA';
// Validiere das Formular vor dem Absenden
const validateForm = (): boolean => {
const newErrors: {
name?: string;
systemPrompt?: string;
} = {};
if (!name.trim()) {
newErrors.name = 'Bitte gib einen Namen ein.';
}
if (!systemPrompt.trim()) {
newErrors.systemPrompt = 'Der System-Prompt darf nicht leer sein.';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle submit
const handleSubmit = () => {
if (!validateForm()) return;
onSubmit({
id: initialData?.id,
name,
description: description.trim() || null,
system_prompt: systemPrompt,
initial_question: initialQuestion.trim() || null,
color: selectedColor,
model_id: selectedModelId || null,
document_mode: documentMode
});
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={[styles.container, { backgroundColor: bgColor }]}
>
<ScrollView style={styles.scrollView}>
<View style={styles.form}>
{/* Titel */}
<Text style={[styles.title, { color: textColor }]}>
{isEditMode ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}
</Text>
{/* Name */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Name *</Text>
<TextInput
style={[
styles.input,
{
color: textColor,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: errors.name ? '#FF3B30' : borderColor
}
]}
placeholder="Name der Vorlage"
placeholderTextColor={placeholderColor}
value={name}
onChangeText={setName}
maxLength={50}
/>
{errors.name && (
<Text style={styles.errorText}>{errors.name}</Text>
)}
</View>
{/* Beschreibung */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Beschreibung (optional)</Text>
<TextInput
style={[
styles.input,
styles.textArea,
{
color: textColor,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor
}
]}
placeholder="Kurze Beschreibung dieser Vorlage"
placeholderTextColor={placeholderColor}
value={description}
onChangeText={setDescription}
multiline
numberOfLines={2}
maxLength={200}
/>
</View>
{/* System-Prompt */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>System-Prompt *</Text>
<TextInput
style={[
styles.input,
styles.textArea,
{
color: textColor,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: errors.systemPrompt ? '#FF3B30' : borderColor,
height: 150
}
]}
placeholder="System-Prompt für die KI"
placeholderTextColor={placeholderColor}
value={systemPrompt}
onChangeText={setSystemPrompt}
multiline
textAlignVertical="top"
/>
{errors.systemPrompt && (
<Text style={styles.errorText}>{errors.systemPrompt}</Text>
)}
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
Der System-Prompt definiert die Rolle und das Verhalten der KI.
</Text>
</View>
{/* Initiale Frage */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Beispielfrage (optional)</Text>
<TextInput
style={[
styles.input,
styles.textArea,
{
color: textColor,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: borderColor,
height: 80
}
]}
placeholder="Beispiel für eine passende Frage oder Anweisung"
placeholderTextColor={placeholderColor}
value={initialQuestion}
onChangeText={setInitialQuestion}
multiline
textAlignVertical="top"
/>
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
Diese Frage wird als Vorschlag angezeigt, wenn die Vorlage ausgewählt wird.
</Text>
</View>
{/* Farbe auswählen */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Farbe</Text>
<View style={styles.colorPicker}>
{TEMPLATE_COLORS.map((color) => (
<TouchableOpacity
key={color}
style={[
styles.colorOption,
{ backgroundColor: color },
selectedColor === color && styles.selectedColorOption
]}
onPress={() => setSelectedColor(color)}
>
{selectedColor === color && (
<Ionicons name="checkmark" size={16} color="white" />
)}
</TouchableOpacity>
))}
</View>
</View>
{/* Modell auswählen */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Bevorzugtes Modell (optional)</Text>
<ModelDropdown
selectedModelId={selectedModelId}
onSelectModel={setSelectedModelId}
/>
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
Falls ausgewählt, wird dieses Modell automatisch mit der Vorlage verwendet.
</Text>
</View>
{/* Dokumentmodus */}
<View style={styles.formGroup}>
<Text style={[styles.label, { color: textColor }]}>Dokumentmodus</Text>
<TouchableOpacity
style={[
styles.switchContainer,
{
backgroundColor: documentMode ? colors.primary + '20' : isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: documentMode ? colors.primary : borderColor
}
]}
onPress={() => setDocumentMode(!documentMode)}
>
<View style={styles.switchText}>
<Text style={[styles.switchLabel, { color: textColor }]}>
Dokumentmodus aktivieren
</Text>
<Text style={[styles.switchDescription, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
Ermöglicht die Bearbeitung eines Dokuments während der Konversation
</Text>
</View>
<View style={[
styles.switchButton,
{ backgroundColor: documentMode ? colors.primary : isDarkMode ? '#636366' : '#C7C7CC' }
]}>
{documentMode ? (
<Ionicons name="checkmark" size={14} color="white" />
) : (
<Ionicons name="close" size={14} color="white" />
)}
</View>
</TouchableOpacity>
</View>
{/* Buttons */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, styles.cancelButton, { borderColor }]}
onPress={onCancel}
>
<Text style={[styles.buttonText, { color: textColor }]}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.submitButton, { backgroundColor: colors.primary }]}
onPress={handleSubmit}
>
<Text style={[styles.buttonText, { color: 'white' }]}>
{isEditMode ? 'Speichern' : 'Erstellen'}
</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
form: {
padding: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
},
formGroup: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: '500',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
textArea: {
height: 80,
textAlignVertical: 'top',
},
helperText: {
fontSize: 12,
marginTop: 6,
},
errorText: {
fontSize: 12,
color: '#FF3B30',
marginTop: 6,
},
colorPicker: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 10,
},
colorOption: {
width: 36,
height: 36,
borderRadius: 18,
margin: 5,
justifyContent: 'center',
alignItems: 'center',
},
selectedColorOption: {
borderWidth: 2,
borderColor: 'white',
},
switchContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderWidth: 1,
borderRadius: 8,
padding: 12,
},
switchText: {
flex: 1,
marginRight: 12,
},
switchLabel: {
fontSize: 16,
fontWeight: '500',
marginBottom: 4,
},
switchDescription: {
fontSize: 12,
},
switchButton: {
width: 24,
height: 24,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 30,
},
button: {
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
minWidth: 120,
alignItems: 'center',
},
cancelButton: {
borderWidth: 1,
},
submitButton: {
backgroundColor: '#0A84FF',
},
buttonText: {
fontSize: 16,
fontWeight: '500',
},
});

View file

@ -0,0 +1,103 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Animated, Easing } from 'react-native';
import { useTheme } from '@react-navigation/native';
type TypingIndicatorProps = {
dotCount?: number;
dotSize?: number;
dotColor?: string;
style?: any;
};
export default function TypingIndicator({
dotCount = 3,
dotSize = 8,
dotColor,
style,
}: TypingIndicatorProps) {
const { colors } = useTheme();
const [animations] = useState(() =>
Array.from({ length: dotCount }).map(() => new Animated.Value(0))
);
// Dotfarbe wird entweder von Prop oder vom Theme übernommen
const actualDotColor = dotColor || colors.text;
useEffect(() => {
// Animiere jeden Punkt mit einer Verzögerung
const animateDots = () => {
const animationSequence = animations.map((anim, i) =>
Animated.sequence([
// Verzögerung für jeden Punkt
Animated.delay(i * 150),
// Animation nach oben
Animated.timing(anim, {
toValue: 1,
duration: 400,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
// Animation zurück nach unten
Animated.timing(anim, {
toValue: 0,
duration: 400,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
// Verzögerung am Ende
Animated.delay((dotCount - i - 1) * 150),
])
);
// Starte alle Animationen parallel und in einer Schleife
Animated.loop(Animated.parallel(animationSequence)).start();
};
animateDots();
// Cleanup beim Unmount
return () => {
animations.forEach(anim => anim.stopAnimation());
};
}, [animations, dotCount]);
return (
<View style={[styles.container, style]}>
{animations.map((anim, index) => (
<Animated.View
key={index}
style={[
styles.dot,
{
width: dotSize,
height: dotSize,
backgroundColor: actualDotColor,
borderRadius: dotSize / 2,
marginHorizontal: dotSize / 3,
transform: [
{
translateY: anim.interpolate({
inputRange: [0, 1],
outputRange: [0, -dotSize],
}),
},
],
},
]}
/>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
dot: {
opacity: 0.6,
},
});

View file

@ -0,0 +1,56 @@
/**
* Model Configuration
* Note: API keys are now stored securely in the backend
* This file only contains model definitions for the mobile app UI
*/
// Available models for the chat application
// These match the models configured in the backend
export const availableModels = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'GPT-O3-Mini',
description: 'Azure OpenAI O3-Mini: Effizientes Modell für schnelle Antworten.',
parameters: {
temperature: 0.7,
max_tokens: 800,
provider: 'azure',
deployment: 'gpt-o3-mini-se',
}
},
{
id: '550e8400-e29b-41d4-a716-446655440004',
name: 'GPT-4o-Mini',
description: 'Azure OpenAI GPT-4o-Mini: Kompaktes, leistungsstarkes KI-Modell.',
parameters: {
temperature: 0.7,
max_tokens: 1000,
provider: 'azure',
deployment: 'gpt-4o-mini-se',
}
},
{
id: '550e8400-e29b-41d4-a716-446655440005',
name: 'GPT-4o',
description: 'Azure OpenAI GPT-4o: Das fortschrittlichste multimodale KI-Modell.',
parameters: {
temperature: 0.7,
max_tokens: 1200,
provider: 'azure',
deployment: 'gpt-4o-se',
}
}
];
// Helper function to get model by ID
export function getModelById(modelId: string) {
return availableModels.find(m => m.id === modelId);
}
// Helper function to get model by deployment name
export function getModelByDeployment(deployment: string) {
return availableModels.find(m => m.parameters.deployment === deployment);
}
// Default model
export const defaultModel = availableModels[0];

View file

@ -0,0 +1,161 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { supabase } from '../utils/supabase';
import { Session, User } from '@supabase/supabase-js';
import { ActivityIndicator, View, Text } from 'react-native';
// Definiere den Typ für den Auth-Kontext
type AuthContextType = {
session: Session | null;
user: User | null;
loading: boolean;
signIn: (email: string, password: string) => Promise<{ error: any | null }>;
signUp: (email: string, password: string) => Promise<{ error: any | null, data: any | null }>;
signOut: () => Promise<void>;
resetPassword: (email: string) => Promise<{ error: any | null }>;
};
// Erstelle den Auth-Kontext
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Hook für den Zugriff auf den Auth-Kontext
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// AuthProvider-Komponente
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// Initialisiere den Auth-Status
useEffect(() => {
// Hole die aktuelle Session
const getInitialSession = async () => {
try {
setLoading(true);
// Prüfe, ob bereits eine Session existiert
const { data: { session } } = await supabase.auth.getSession();
setSession(session);
setUser(session?.user ?? null);
// Abonniere Änderungen am Auth-Status
const { data: { subscription } } = await supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
}
);
return () => {
subscription.unsubscribe();
};
} catch (error) {
console.error('Fehler beim Initialisieren der Auth-Session:', error);
} finally {
setLoading(false);
}
};
getInitialSession();
}, []);
// Anmelden mit E-Mail und Passwort
const signIn = async (email: string, password: string) => {
try {
console.log('Versuche Anmeldung mit:', email);
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
console.error('Supabase Auth Fehler:', error.message, error.status);
return { error };
}
console.log('Anmeldung erfolgreich:', data.user?.id);
return { error: null };
} catch (error: any) {
console.error('Unerwarteter Fehler beim Anmelden:', error.message || error);
return { error };
}
};
// Registrieren mit E-Mail und Passwort
const signUp = async (email: string, password: string) => {
try {
// Registriere den Benutzer mit autoConfirm=true, um die E-Mail-Bestätigung zu umgehen
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
email_confirmed: true
}
}
});
if (!error && data?.user) {
// Wenn die Registrierung erfolgreich war, melde den Benutzer direkt an
await signIn(email, password);
}
return { data, error };
} catch (error) {
console.error('Fehler beim Registrieren:', error);
return { data: null, error };
}
};
// Abmelden
const signOut = async () => {
try {
await supabase.auth.signOut();
} catch (error) {
console.error('Fehler beim Abmelden:', error);
}
};
// Passwort zurücksetzen
const resetPassword = async (email: string) => {
try {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: 'exp://localhost:8081/reset-password',
});
return { error };
} catch (error) {
console.error('Fehler beim Zurücksetzen des Passworts:', error);
return { error };
}
};
// Zeige Ladeindikator während der Initialisierung
if (loading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#0A84FF" />
<Text style={{ marginTop: 16 }}>Authentifizierung wird initialisiert...</Text>
</View>
);
}
// Stelle den Auth-Kontext bereit
return (
<AuthContext.Provider
value={{
session,
user,
loading,
signIn,
signUp,
signOut,
resetPassword,
}}
>
{children}
</AuthContext.Provider>
);
}

21
chat/apps/mobile/eas.json Normal file
View file

@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 15.0.15",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View file

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

View file

@ -0,0 +1,50 @@
import { useState } from 'react';
import { useAppTheme } from '../theme/ThemeProvider';
interface UseChatInputProps {
onSend: (message: string) => void;
isLoading?: boolean;
initialText?: string;
placeholder?: string;
maxLength?: number;
}
interface UseChatInputReturn {
text: string;
setText: (text: string) => void;
handleSend: () => void;
canSend: boolean;
isLoading: boolean;
isDarkMode: boolean;
placeholder: string;
}
export default function useChatInput({
onSend,
isLoading = false,
initialText = '',
placeholder = 'Nachricht eingeben...',
maxLength = 1000,
}: UseChatInputProps): UseChatInputReturn {
const [text, setText] = useState(initialText);
const { isDarkMode } = useAppTheme();
const canSend = text.trim().length > 0 && !isLoading;
const handleSend = () => {
if (canSend) {
onSend(text.trim());
setText('');
}
};
return {
text,
setText,
handleSend,
canSend,
isLoading,
isDarkMode,
placeholder,
};
}

View file

@ -0,0 +1,9 @@
// 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} */
// eslint-disable-next-line no-undef
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });

3
chat/apps/mobile/nativewind-env.d.ts vendored Normal file
View file

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

View file

@ -0,0 +1,69 @@
{
"name": "@chat/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"type": "commonjs",
"scripts": {
"dev": "expo start --dev-client",
"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",
"supabase:cli": "node --experimental-json-modules scripts/supabase-cli.js",
"supabase:update-models": "node --experimental-json-modules scripts/update_models.js",
"supabase:setup": "node --experimental-json-modules scripts/setup_supabase.js",
"supabase:setup-spaces": "node --experimental-json-modules scripts/spaces/setup_spaces.js"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "^7.0.5",
"@react-navigation/drawer": "^7.0.0",
"@react-navigation/native": "^7.0.3",
"@supabase/supabase-js": "^2.38.4",
"expo": "^52.0.39",
"expo-constants": "~17.0.8",
"expo-dev-client": "~5.0.4",
"expo-dev-launcher": "^5.0.17",
"expo-linking": "~7.0.5",
"expo-router": "~4.0.6",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8",
"expo-web-browser": "~14.0.2",
"nativewind": "latest",
"node-fetch": "^2.7.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.7",
"react-native-gesture-handler": "~2.20.2",
"react-native-markdown-display": "^7.0.2",
"react-native-reanimated": "3.16.2",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-web": "~0.19.10"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~18.3.12",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"dotenv": "^16.4.7",
"eslint": "^8.57.0",
"eslint-config-universe": "^12.0.1",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.0",
"typescript": "~5.3.3"
},
"eslintConfig": {
"extends": "universe/native",
"root": true
},
"private": true
}

View file

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

View file

@ -0,0 +1,561 @@
API Routes
Learn how to create server endpoints with Expo Router.
Expo Router enables you to write secure server code for all platforms, right in your app directory.
app/hello+api.ts
Copy
export function GET(request: Request) {
return Response.json({ hello: 'world' });
}
Server features require a custom server, which can be deployed to EAS or most other hosting providers.
Watch: Expo Router API Routes Handle Requests & Stream Data
Watch: Expo Router API Routes Handle Requests & Stream Data
What are API Routes
API Routes are functions that are executed on a server when a route is matched. They can be used to handle sensitive data, such as API keys securely, or implement custom server logic, such as exchanging auth codes for access tokens. API Routes should be executed in a WinterCG-compliant environment.
In Expo, API Routes are defined by creating files in the app directory with the +api.ts extension. For example, the following API route is executed when the route /hello is matched.
app
index.tsx
hello+api.ts
API Route
Create an API route
1
Ensure your project is using server output, this will configure the export and production builds to generate a server bundle as well as the client bundle.
app.json
Copy
{
"web": {
"output": "server"
}
}
2
An API route is created in the app directory. For example, add the following route handler. It is executed when the route /hello is matched.
app/hello+api.ts
Copy
export function GET(request: Request) {
return Response.json({ hello: 'world' });
}
You can export any of the following functions GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS from a server route. The function executes when the corresponding HTTP method is matched. Unsupported methods will automatically return 405: Method not allowed.
3
Start the development server with Expo CLI:
Terminal
Copy
npx expo
4
You can make a network request to the route to access the data. Run the following command to test the route:
Terminal
Copy
curl http://localhost:8081/hello
You can also make a request from the client code:
app/index.tsx
Copy
import { Button } from 'react-native';
async function fetchHello() {
const response = await fetch('/hello');
const data = await response.json();
alert('Hello ' + data.hello);
}
export default function App() {
return <Button onPress={() => fetchHello()} title="Fetch hello" />;
}
Relative fetch requests automatically fetch relative to the dev server origin in development, and can be configured in production using the origin field in the app.json:
app.json
Copy
{
"plugins": [
[
"expo-router",
{
"origin": "https://evanbacon.dev/"
}
]
]
}
This URL can be automatically configured during EAS Builds by setting the EXPO_UNSTABLE_DEPLOY_SERVER=1 environment variable. This will trigger a versioned server deployment which sets the origin to a preview deploy URL automatically.
5
Deploy the website and server to a hosting provider to access the routes in production on both native and web.
API route filenames cannot have platform-specific extensions. For example, hello+api.web.ts will not work.
Requests
Requests use the global, standard Request object.
app/blog/[post]+api.ts
Copy
export async function GET(request: Request, { post }: Record<string, string>) {
// const postId = new URL(request.url).searchParams.get('post')
// fetch data for 'post'
return Response.json({ ... });
}
Request body
Use the request.json() function to access the request body. It automatically parses the body and returns the result.
app/validate+api.ts
Copy
export async function POST(request: Request) {
const body = await request.json();
return Response.json({ ... });
}
Request query parameters
Query parameters can be accessed by parsing the request URL:
app/endpoint+api.ts
Copy
export async function GET(request: Request) {
const url = new URL(request.url);
const post = url.searchParams.get('post');
// fetch data for 'post'
return Response.json({ ... });
}
Response
Responses use the global, standard Response object.
app/demo+api.ts
Copy
export function GET() {
return Response.json({ hello: 'universe' });
}
Errors
You can respond to server errors by using the Response object.
app/blog/[post].ts
Copy
import { Request, Response } from 'expo-router/server';
export async function GET(request: Request, { post }: Record<string, string>) {
if (!post) {
return new Response('No post found', {
status: 404,
headers: {
'Content-Type': 'text/plain',
},
});
}
// fetch data for `post`
return Response.json({ ... });
}
Making requests with an undefined method will automatically return 405: Method not allowed. If an error is thrown during the request, it will automatically return 500: Internal server error.
Bundling
API Routes are bundled with Expo CLI and Metro bundler. They have access to all of the language features as your client code:
TypeScript — types and tsconfig.json paths.
Environment variables — server routes have access to all environment variables, not just the ones prefixed with EXPO_PUBLIC_.
Node.js standard library — ensure that you are using the correct version of Node.js locally for your server environment.
babel.config.js and metro.config.js support — settings work across both client and server code.
Security
Route handlers are executed in a sandboxed environment that is isolated from the client code. It means you can safely store sensitive data in the route handlers without exposing it to the client.
Client code that imports code with a secret is included in the client bundle. It applies to all files in the app directory even though they are not a route handler file (such as suffixed with +api.ts).
If the secret is in a <...>+api.ts file, it is not included in the client bundle. It applies to all files that are imported in the route handler.
The secret stripping takes place in expo/metro-config and requires it to be used in the metro.config.js.
Deployment
When you're ready to deploy to production, run npx expo export --platform web to create the server bundle in the dist directory. This server can be tested locally with npx expo serve (available in Expo SDK 52 and higher), visit the URL in a web browser or create a native build with the origin set to the local server URL. You can deploy the server for production using EAS Hosting or another third-party service.
Deploy instantly with EAS
EAS Hosting is the best way to deploy your Expo API routes and servers.
Native deployment
This is an experimental feature starting in SDK 52 and above. The process will be more automated and have better support in future versions.
Server features (API routes, and React Server Components) in Expo Router are centered around native implementations of window.location and fetch which point to the remote server. In development, we automatically point to the dev server running with npx expo start, but for production native builds to work you'll need to deploy the server to a secure host and set the origin property of the Expo Router Config Plugin.
When configured, features like relative fetch requests fetch('/my-endpoint') will automatically point to the server origin.
This deployment process can experimentally be automated to ensure correct versioning during native builds with the EXPO_UNSTABLE_DEPLOY_SERVER=1 environment variable.
Here's how to configure your native app to automatically deploy and link a versioned production server on build:
1
Ensure the origin field is NOT set in the app.json or in the expo.extra.router.origin field. Also, ensure you aren't using app.config.js as this is not supported with automatically linked deployments yet.
2
Setup EAS Hosting for the project by deploying once locally first.
Terminal
npx expo export -p web
eas deploy
3
Set the EXPO_UNSTABLE_DEPLOY_SERVER environment variable in your .env file. This will be used to enable the experimental server deployment functionality during EAS Build.
.env
Copy
EXPO_UNSTABLE_DEPLOY_SERVER=1
4
You're now ready to use automatic server deployment! Run the build command to start the process.
Terminal
Copy
eas build
You can also run this locally with:
Terminal
npx expo run:android --variant release
npx expo run:ios --configuration Release
Notes about automatic server deployment for native apps:
Server failures may occur during the Bundle JavaScript phase of EAS Build if something was not setup correctly.
You can manually deploy the server and set the origin URL before building the app if you'd like.
Automatic deployment can be force skipped with the environment variable EXPO_NO_DEPLOY=1.
Automatic deployment does not support dynamic app config (app.config.js and app.config.ts) files yet.
Logs from the deployment will be written to .expo/logs/deploy.log.
Deployment will not run in EXPO_OFFLINE mode.
Testing the native production app locally
It can often be useful to test the production build against a local dev server to ensure everything is working as expected. This can speed up the debugging process substantially.
1
Export the production server:
Terminal
Copy
npx expo export
2
Host the production server locally:
Terminal
Copy
npx expo serve
3
Set the origin in the app.json's origin field. Ensure no generated value is in expo.extra.router.origin. This should be http://localhost:8081 (assuming npx expo serve is running on the default port).
app.json
Copy
{
"expo": {
"plugins": [
[
"expo-router",
{
"origin": "http://localhost:8081"
}
]
]
}
}
Remember to remove this origin value when deploying to production.
4
Build the app in release mode on to a simulator:
Terminal
Copy
EXPO_NO_DEPLOY=1 npx expo run:ios --configuration Release
You should now see requests coming in to the local server. Use a tool like Proxyman to inspect network traffic for the simulator and gain better insight.
You can experimentally change the URL and quickly rebuild for iOS using the --unstable-rebundle flag. This will swap out the app.json and client assets for new ones, skipping the native rebuild.
For example, you can run eas deploy to get a new deployment URL, add it to the app.json, then run npx expo run:ios --unstable-rebundle --configuration Release to quickly rebuild the app with the new URL.
You will want to make a clean build before sending to the store to ensure no transient issues are present.
Hosting on third-party services
This is experimental and subject to breaking changes. We have no continuous tests against this configuration.
Every cloud hosting provider needs a custom adapter to support the Expo server runtime. The following third-party providers have unofficial or experimental support from the Expo team.
Before deploying to these providers, it may be good to be familiar with the basics of npx expo export command:
dist is the default export directory for Expo CLI.
Files in public directory are copied to dist on export.
The @expo/server package is included with expo and delegates requests to the server routes.
@expo/server does not inflate environment variables from .env files. They are expected to load either by the hosting provider or the user.
Metro is not included in the server.
Express
1
Install the required dependencies:
Terminal
Copy
npm i -D express compression morgan
2
Export the website for production:
Terminal
Copy
npx expo export -p web
3
Write a server entry file that serves the static files and delegates requests to the server routes:
server.ts
Copy
#!/usr/bin/env node
const path = require('path');
const { createRequestHandler } = require('@expo/server/adapter/express');
const express = require('express');
const compression = require('compression');
const morgan = require('morgan');
const CLIENT_BUILD_DIR = path.join(process.cwd(), 'dist/client');
const SERVER_BUILD_DIR = path.join(process.cwd(), 'dist/server');
const app = express();
app.use(compression());
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable('x-powered-by');
process.env.NODE_ENV = 'production';
app.use(
express.static(CLIENT_BUILD_DIR, {
maxAge: '1h',
extensions: ['html'],
})
);
app.use(morgan('tiny'));
app.all(
'*',
createRequestHandler({
build: SERVER_BUILD_DIR,
})
);
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Express server listening on port ${port}`);
});
Show More
4
Start the server with node command:
Terminal
Copy
node server.ts
Netlify
This is experimental and subject to breaking changes. We have no continuous tests against this configuration.
1
Create a server entry file. All requests will be delegated through this middleware. The exact file location is important.
netlify/functions/server.ts
Copy
const { createRequestHandler } = require('@expo/server/adapter/netlify');
const handler = createRequestHandler({
build: require('path').join(__dirname, '../../dist/server'),
});
module.exports = { handler };
2
Create a Netlify configuration file at the root of your project to redirect all requests to the server function.
netlify.toml
Copy
[build]
command = "expo export -p web"
functions = "netlify/functions"
publish = "dist/client"
[[redirects]]
from = "/*"
to = "/.netlify/functions/server"
status = 404
[functions]
# Include everything to ensure dynamic routes can be used.
included_files = ["dist/server/**/*"]
[[headers]]
for = "/dist/server/_expo/functions/*"
[headers.values]
# Set to 60 seconds as an example.
"Cache-Control" = "public, max-age=60, s-maxage=60"
Show More
3
After you have created the configuration files, you can build the website and functions with Expo CLI:
Terminal
Copy
npx expo export -p web
4
Deploy to Netlify with the Netlify CLI.
Terminal
npm install netlify-cli -g
netlify deploy
You can now visit your website at the URL provided by Netlify CLI. Running netlify deploy --prod will publish to the production URL.
5
If you're using any environment variables or .env files, add them to Netlify. You can do this by going to the Site settings and adding them to the Build & deploy section.
Vercel
This is experimental and subject to breaking changes. We have no continuous tests against this configuration.
1
Create a server entry file. All requests will be delegated through this middleware. The exact file location is important.
api/index.ts
Copy
const { createRequestHandler } = require('@expo/server/adapter/vercel');
module.exports = createRequestHandler({
build: require('path').join(__dirname, '../dist/server'),
});
2
Create a Vercel configuration file (vercel.json) at the root of your project to redirect all requests to the server function.
vercel.json v3
vercel.json v2
vercel.json
Copy
{
"buildCommand": "expo export -p web",
"outputDirectory": "dist/client",
"functions": {
"api/index.ts": {
"runtime": "@vercel/node@5.1.8",
"includeFiles": "dist/server/**"
}
},
"rewrites": [
{
"source": "/(.*)",
"destination": "/api/index"
}
]
}
The newer version of the vercel.json does not use routes and builds configuration options anymore, and serves your public assets from the dist/client output directory automatically.
3
Note: This step only applies to users of the legacy version of the vercel.json. If you're using v3, you can skip this step.
After you have created the configuration files, add a vercel-build script to your package.json file and set it to expo export -p web.
4
Deploy to Vercel with the Vercel CLI.
Terminal
npm install vercel -g
vercel build
vercel deploy --prebuilt
You can now visit your website at the URL provided by the Vercel CLI.
Known limitations
Several known features are not currently supported in the API Routes beta release.
No dynamic imports
API Routes currently work by bundling all code (minus the Node.js built-ins) into a single file. This means that you cannot use any external dependencies that are not bundled with the server. For example, a library such as sharp, which includes multiple platform binaries, cannot be used. This will be addressed in a future version.
ESM not supported
The current bundling implementation opts to be more unified than flexible. This means the limitation of native not supporting ESM is carried over to API Routes. All code will be transpiled down to Common JS (require/module.exports). However, we recommend you write API Routes using ESM regardless. This will be addressed in a future version.

View file

@ -0,0 +1,129 @@
Version: v4
Overview
What is NativeWind?
Do you like using Tailwind CSS to style your apps? This helps you do that in React Native. NativeWind is not a component library, it's a styling library. If you're looking for component libraries that support NativeWind, see Component Libraries.
NativeWind makes sure you're using the best styling engine for any given platform (e.g. CSS StyleSheet on web and StyleSheet.create for native). Its primary goal is to provide a consistent styling experience across all platforms via robust coverage of Tailwind CSS. We believe that this is at the core of an exceptional developer experience. As we begin to wrap up this robust coverage, we have begun focus on our secondary goal, component performance.
NativeWind processes your styles during your application's build step and uses a minimal runtime to selectively apply reactive styles (eg changes to device orientation, light dark mode).
NativeWind allows you to use Tailwind CSS to style your components in React Native. Styled components can be shared between all React Native platforms, using the best style engine for that platform; CSS StyleSheet on web and StyleSheet.create for native. Its goals are to provide a consistent styling experience across all platforms, improving Developer UX, component performance and code maintainability.
On native platforms, NativeWind performs two functions. First, at build time, it compiles your Tailwind CSS styles into StyleSheet.create objects and determines the conditional logic of styles (e.g. hover, focus, active, etc). Second, it has an efficient runtime system that applies the styles to your components. This means you can use the full power of Tailwind CSS, including media queries, container queries, and custom values, while still having the performance of a native style system.
On web, NativeWind is a small polyfill for adding className support to React Native Web.
Key Features
🌐 Universal - Uses the best styling engine for each platform - CSS StyleSheet on web, StyleSheet.create for native
🖥️ Developer UX - Simple setup with plugins for improved intellisense support and automatic TypeScript configuration
🎨 CSS Variables - Create themes, sub-themes and dynamic styles using CSS custom properties (variables) that work across platforms
✨ Robust Animations - Full support for Tailwind's animation classes and custom keyframe animations via react-native-reanimated
🔄 Transitions - Smooth transitions between style states, including dark mode changes and dynamic updates
👪 Tailwind Groups & Parent State - Style children based on parent state using Tailwind's group syntax and modifiers (docs)
📱 Media & Container Queries - Responsive styles using modern mobile features like media and container queries (docs)
✨ Pseudo classes hover / focus / active on compatible components (docs)
📏 rem Support - Use rem units consistently across platforms with automatic conversion
🔍 Dot Notation Support - Access nested style properties using familiar dot notation
🎯 Custom CSS - Write custom CSS that gets compiled to native styles while preserving performance
In action
NativeWind handles both the Tailwind CSS compilation and the runtime styles. It works via a JSX transform, meaning there is no need for custom wrappers/boilerplate.
As all React components are transformed with JSX, it works with 3rd party modules. This assumes that the 3rd party module in question allows you to pass through the className prop.
import { CustomText } from "third-party-text-component";
export function BoldText(props) {
// You just need to write `className="<your styles>"`
return <CustomText className="text-bold" {...props} />;
}
Styling can by dynamic and you can perform conditional logic and build up complex style objects.
import { Text } from "react-native";
export function MyText({ bold, italic, lineThrough, ...props }) {
const classNames = [];
if (bold) classNames.push("font-bold");
if (italic) classNames.push("italic");
if (lineThrough) classNames.push("line-through");
return <Text className={classNames.join(" ")} {...props} />;
}
Manually styling components via cssInterop() and remapProps()
By default NativeWind maps className->style, but it can handle the mapping of complex components. If you'd like to manually wrap a component, you can use cssInterop() docs. If you run into any performance issues, we suggest trying out remapProps(). While less robust in its coverage, it should lead to improved performance.
remapProps(FlatList, {
className: "style",
ListFooterComponentClassName: "ListFooterComponentStyle",
ListHeaderComponentClassName: "ListHeaderComponentStyle",
columnWrapperClassName: "columnWrapperStyle",
contentContainerClassName: "contentContainerStyle",
});
<FlatList
{...}
className="bg-black"
ListHeaderComponentClassName="bg-black text-white"
ListFooterComponentClassName="bg-black text-white"
columnWrapperClassName="bg-black"
contentContainerClassName="bg-black"
indicatorClassName="bg-black"
/>
And can even work with components that expect style attributes as props
import { Text } from "react-native";
import { cssInterop } from "nativewind";
import { Svg, Circle } from "react-native-svg";
/**
* Circle uses `height`/`width` props on native and className on web
*/
const StyledSVG = cssInterop(Svg, {
className: {
target: "style",
nativeStyleToProp: {
height: true,
width: true,
},
},
});
/**
* Circle uses `fill`/`stroke`/`strokeWidth` props on native and className on web
*/
const StyledCircle = cssInterop(Circle, {
className: {
target: "style",
nativeStyleToProp: {
fill: true,
stroke: true,
strokeWidth: true,
},
},
});
export function BoldText(props) {
return (
<Svg className="w-1/2 h-1/2" viewBox="0 0 100 100">
<StyledCircle
className="fill-green-500 stroke-blue-500 stroke-2"
cx="50"
cy="50"
r="45"
/>
</Svg>
);
}

View file

@ -0,0 +1,101 @@
# Chat Application
Eine moderne Chat-Anwendung mit LLM-Integration, entwickelt mit Expo React Native und Supabase.
## Projektübersicht
Diese Anwendung ermöglicht Benutzern, mit verschiedenen LLM-Modellen zu interagieren. Sie unterstützt verschiedene Konversationsmodi:
- **Freier Modus**: Offene Gespräche mit dem KI-Modell
- **Geführter Modus**: Strukturierte Konversationen mit vordefinierten Schritten
- **Vorlagen-Modus**: Vordefinierte Gesprächsabläufe für spezifische Anwendungsfälle
## Datenbankstruktur
Die Anwendung verwendet Supabase mit folgender Datenbankstruktur:
### Users
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| id | UUID (PK) | Eindeutige Benutzer-ID |
| email | String | E-Mail-Adresse des Benutzers |
| name | String | Name des Benutzers |
| created_at | Timestamp | Erstellungszeitpunkt |
| updated_at | Timestamp | Letzter Aktualisierungszeitpunkt |
### Conversations
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| id | UUID (PK) | Eindeutige Konversations-ID |
| user_id | UUID (FK) | Referenz zum Benutzer |
| model_id | UUID (FK) | Referenz zum verwendeten LLM-Modell |
| template_id | UUID (FK, Nullable) | Optionale Referenz zur verwendeten Vorlage |
| conversation_mode | String | Modus der Konversation (frei, geführt, vorlage) |
| created_at | Timestamp | Erstellungszeitpunkt |
| updated_at | Timestamp | Letzter Aktualisierungszeitpunkt |
### Messages
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| id | UUID (PK) | Eindeutige Nachrichten-ID |
| conversation_id | UUID (FK) | Referenz zur Konversation |
| sender | Enum | Absender der Nachricht (user, ai) |
| message_text | Text | Inhalt der Nachricht |
| created_at | Timestamp | Erstellungszeitpunkt |
| updated_at | Timestamp | Letzter Aktualisierungszeitpunkt |
### Models
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| id | UUID (PK) | Eindeutige Modell-ID |
| name | String | Name des Modells (z.B. GPT-4, GPT-3) |
| description | Text | Beschreibung des Modells |
| parameters | JSON | Optionale Einstellungen für das Modell |
### Templates
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| id | UUID (PK) | Eindeutige Vorlagen-ID |
| name | String | Name der Vorlage (z.B. "Vertrag erstellen") |
| description | Text | Beschreibung der Vorlage |
| mode_type | Text | Detaillierte Beschreibung des Modus/Guided Flow |
| initial_questions | JSON/Text | Startfragen oder Anweisungen |
| created_at | Timestamp | Erstellungszeitpunkt |
| updated_at | Timestamp | Letzter Aktualisierungszeitpunkt |
## Beziehungen
- Ein Benutzer kann mehrere Konversationen haben
- Eine Konversation gehört zu einem Benutzer und verwendet ein Modell
- Eine Konversation kann optional eine Vorlage verwenden
- Eine Konversation enthält mehrere Nachrichten
- Jede Nachricht gehört zu einer Konversation
## Technologie-Stack
- **Frontend**: Expo React Native
- **Backend/Datenbank**: Supabase
- **Authentifizierung**: Supabase Auth
- **LLM-Integration**: Verschiedene KI-Modelle (GPT-4, GPT-3, etc.)
## Funktionen
- Benutzerregistrierung und -anmeldung
- Erstellen und Verwalten von Konversationen
- Auswahl verschiedener KI-Modelle
- Unterstützung für verschiedene Konversationsmodi
- Vorlagensystem für spezifische Anwendungsfälle
- Nachrichtenverlauf und -speicherung
## Installation und Einrichtung
1. Repository klonen
2. Abhängigkeiten installieren: `npm install` oder `yarn install`
3. Supabase-Projekt einrichten und Verbindungsdaten konfigurieren
4. Umgebungsvariablen in `.env` konfigurieren
5. Anwendung starten: `expo start`
## Entwicklungshinweise
- Supabase-Tabellen entsprechend der oben beschriebenen Struktur einrichten
- Sicherstellen, dass alle Fremdschlüsselbeziehungen korrekt konfiguriert sind
- API-Schlüssel für LLM-Modelle sicher verwalten

View file

@ -0,0 +1,188 @@
# Supabase-Integration und Datenbank-Verwaltung
Diese Dokumentation beschreibt die Integration von Supabase in die Chat-Anwendung und erklärt die Verwendung der bereitgestellten Skripte zur Datenbankverwaltung.
## Inhaltsverzeichnis
1. [Übersicht](#übersicht)
2. [Datenbankstruktur](#datenbankstruktur)
3. [Einrichtung](#einrichtung)
4. [Skripte zur Datenbankverwaltung](#skripte-zur-datenbankverwaltung)
5. [Fehlerbehebung](#fehlerbehebung)
6. [Häufig gestellte Fragen](#häufig-gestellte-fragen)
## Übersicht
Die Chat-Anwendung verwendet Supabase als Backend-Dienst für:
- Benutzerauthentifizierung
- Datenspeicherung (Konversationen, Nachrichten, Modelle)
- Echtzeit-Updates
## Datenbankstruktur
Die Anwendung verwendet folgende Tabellen in Supabase:
| Tabelle | Beschreibung | Wichtige Felder |
|---------|--------------|-----------------|
| users | Benutzerinformationen | id, email, name, created_at, updated_at |
| conversations | Gespeicherte Konversationen | id, user_id, model_id, template_id, conversation_mode, created_at, updated_at |
| messages | Nachrichten innerhalb von Konversationen | id, conversation_id, sender, message_text, created_at, updated_at |
| models | Verfügbare LLM-Modelle | id, name, description, parameters, created_at, updated_at |
| templates | Konversationsvorlagen | id, name, description, mode_type, initial_questions, created_at, updated_at |
## Einrichtung
### Voraussetzungen
1. Ein Supabase-Konto und -Projekt
2. Node.js und npm installiert
3. Die Umgebungsvariablen in der `.env`-Datei konfiguriert:
```
EXPO_PUBLIC_SUPABASE_URL=https://deine-projekt-id.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=dein-anon-key
```
### Ersteinrichtung
Um die Supabase-Datenbank für die Anwendung einzurichten:
1. Führe das Setup-Skript aus:
```bash
npm run supabase:setup
```
Dieses Skript:
- Erstellt die notwendigen Funktionen in der Datenbank
- Richtet die Tabellen ein, falls sie noch nicht existieren
- Fügt die Standard-Modelle mit korrekten UUIDs ein
## Skripte zur Datenbankverwaltung
Die Anwendung bietet mehrere Skripte zur Verwaltung der Supabase-Datenbank:
### 1. Supabase-Setup (`npm run supabase:setup`)
Richtet die Supabase-Datenbank ein, erstellt notwendige Funktionen und aktualisiert die Modelle.
```bash
npm run supabase:setup
```
### 2. Modell-Update (`npm run supabase:update-models`)
Aktualisiert die Modelle in der Datenbank mit den korrekten UUIDs.
```bash
npm run supabase:update-models
```
### 3. Interaktive Supabase-CLI (`npm run supabase:cli`)
Ein interaktives Kommandozeilen-Tool zur Verwaltung der Datenbank.
```bash
npm run supabase:cli
```
Mit diesem Tool kannst du:
- SQL-Abfragen direkt ausführen
- SQL-Dateien ausführen
- Tabellenlisten anzeigen
- Tabellenstrukturen anzeigen
#### Beispiel-Befehle in der CLI
**Tabellenliste anzeigen:**
```
3 [Tabellenliste anzeigen]
```
**Tabellenstruktur anzeigen:**
```
4 [Tabellenstruktur anzeigen]
conversations
```
**SQL-Abfrage ausführen:**
```
1 [SQL-Abfrage ausführen]
SELECT * FROM models LIMIT 5;
```
## Fehlerbehebung
### Problem: UUID-Fehler bei der Erstellung von Konversationen
**Symptom:** Fehler "invalid input syntax for type uuid" beim Erstellen einer Konversation.
**Lösung:**
1. Führe das Modell-Update-Skript aus, um die Modell-IDs zu korrigieren:
```bash
npm run supabase:update-models
```
2. Überprüfe, ob die Modell-IDs im Frontend mit denen in der Datenbank übereinstimmen:
```bash
npm run supabase:cli
```
Dann wähle Option 1 und führe aus:
```sql
SELECT id, name FROM models;
```
### Problem: Authentifizierungsfehler
**Symptom:** 400 Bad Request bei der Anmeldung.
**Lösung:**
1. Überprüfe, ob der Benutzer in der Auth-Tabelle existiert:
```bash
npm run supabase:cli
```
Dann wähle Option 1 und führe aus:
```sql
SELECT * FROM auth.users WHERE email = 'deine-email@beispiel.de';
```
2. Verwende die Magic Link-Anmeldung als Alternative.
## Häufig gestellte Fragen
### Wie füge ich ein neues Modell hinzu?
1. Füge das Modell zur `FALLBACK_MODELS`-Liste in `app/api/models+api.ts` hinzu
2. Führe das Modell-Update-Skript aus:
```bash
npm run supabase:update-models
```
### Wie kann ich die Datenbankstruktur ändern?
1. Erstelle eine SQL-Datei mit den gewünschten Änderungen
2. Führe die Datei mit der Supabase-CLI aus:
```bash
npm run supabase:cli
```
Dann wähle Option 2 und gib den Pfad zur SQL-Datei ein.
### Wie kann ich die Datenbank zurücksetzen?
1. Verwende die Supabase-CLI:
```bash
npm run supabase:cli
```
2. Wähle Option 1 und führe folgende Befehle aus:
```sql
DELETE FROM messages;
DELETE FROM conversations;
```
3. Führe das Modell-Update-Skript aus, um die Standard-Modelle wiederherzustellen:
```bash
npm run supabase:update-models
```
---
Diese Dokumentation wird kontinuierlich aktualisiert. Bei Fragen oder Problemen, bitte erstelle ein Issue im Repository oder kontaktiere das Entwicklungsteam.

View file

@ -0,0 +1,190 @@
# Token-Erfassung und Kostenabrechnung
Dieses Dokument beschreibt das System zur Erfassung und Abrechnung von Token-Nutzung in der Chat-Anwendung.
## Übersicht
Die Anwendung verfolgt die Nutzung von LLM-Tokens (Large Language Models) und berechnet daraus die entstehenden Kosten. Dies ermöglicht:
- Transparenz über die Ressourcennutzung
- Kostenerfassung pro Benutzer, Konversation und Modell
- Analysen zur Optimierung der Anwendung
- Grundlage für nutzungsbasierte Abrechnungsmodelle
## Datenbank-Schema
### Die `usage_logs` Tabelle
Speichert Informationen über jeden LLM-API-Aufruf:
```sql
CREATE TABLE public.usage_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id),
model_id UUID REFERENCES models(id),
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
estimated_cost DECIMAL(10, 6) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
### Modell-Kosteninformationen
Die `models`-Tabelle wurde um ein `cost_settings`-Feld erweitert, das die Kosten pro Token für jedes Modell enthält:
```sql
ALTER TABLE public.models ADD COLUMN cost_settings JSONB DEFAULT '{"prompt_per_1k_tokens": 0.0001, "completion_per_1k_tokens": 0.0002}'::jsonb;
```
Beispiel für `cost_settings`:
```json
{
"prompt_per_1k_tokens": 0.003, // Kosten pro 1000 Prompt-Tokens in Dollar
"completion_per_1k_tokens": 0.006 // Kosten pro 1000 Completion-Tokens in Dollar
}
```
## Implementierung
### Erfassung der Token-Nutzung
1. **API-Aufruf**:
- Jeder Aufruf eines LLM-Modells über `services/openai.ts` gibt Token-Nutzungsinformationen zurück
- Diese werden aus der API-Antwort in `utils/api.ts` extrahiert
2. **Speicherung**:
- Die Funktion `logTokenUsage` in `services/openai.ts` speichert die Token-Nutzung in der Datenbank
- Sie berechnet auch die geschätzten Kosten basierend auf den Modellpreisen
```typescript
export async function logTokenUsage(
usage: TokenUsage,
conversationId: string,
messageId: string,
userId: string,
modelId: string
): Promise<void>
```
3. **Kostenberechnung**:
- Die Funktion `calculateTokenCost` in `services/openai.ts` berechnet die Kosten pro Anfrage
- Sie berücksichtigt unterschiedliche Preise für Prompt- und Completion-Tokens
```typescript
export async function calculateTokenCost(
promptTokens: number,
completionTokens: number,
modelId: string
): Promise<number>
```
### Abfrage und Analyse
Die folgenden SQL-Funktionen sind für die Abfrage der Token-Nutzung verfügbar:
1. **Nutzung nach Modell**:
```sql
SELECT * FROM get_user_model_usage(user_id);
```
- Gibt die Summe der Token und Kosten pro Modell für einen Benutzer zurück
2. **Nutzung nach Zeitraum**:
```sql
SELECT * FROM get_user_usage_by_period(user_id, 'day');
```
- Akzeptiert 'day', 'month' oder 'year' als Zeitraum
- Gibt die Summe der Token und Kosten pro Zeiteinheit zurück
3. **Nutzung pro Konversation**:
```sql
SELECT * FROM get_conversation_usage(conversation_id);
```
- Gibt die Token-Nutzung für jede Nachricht in einer Konversation zurück
## API-Endpunkte
Die API-Endpunkte für den Zugriff auf die Token-Nutzungsdaten sind:
1. **GET /api/usage**
- Parameter: `userId` (erforderlich), `period` (optional, Standard: 'month')
- Gibt die Token-Nutzung eines Benutzers zurück (nach Modell und Zeitraum)
2. **GET /api/usage/conversation**
- Parameter: `conversationId` (erforderlich)
- Gibt die Token-Nutzung für eine bestimmte Konversation zurück
## Modellpreise (Standardwerte)
Die Standardpreise für verschiedene Modelle sind:
| Modell | Prompt-Tokens (pro 1K) | Completion-Tokens (pro 1K) |
|--------|------------------------|----------------------------|
| GPT-O3-Mini | $0.0001 | $0.0002 |
| GPT-4o-mini | $0.0001 | $0.0002 |
| GPT-4o | $0.003 | $0.006 |
| GPT-4 | $0.003 | $0.006 |
| GPT-3.5 | $0.0001 | $0.0002 |
| Claude | $0.0008 | $0.0024 |
## Verwendungsbeispiele
### Token-Nutzung im Code
```typescript
// Beispiel: Abfrage der Token-Nutzung eines Benutzers
const { data } = await supabase.rpc('get_user_model_usage', { user_id: userId });
console.log('Token-Nutzung nach Modell:', data);
// Beispiel: Abfrage der Token-Nutzung nach Monat
const { data } = await supabase.rpc('get_user_usage_by_period', {
user_id: userId,
period: 'month'
});
console.log('Monatliche Token-Nutzung:', data);
```
### Anzeige in der Benutzeroberfläche
Die Token-Nutzungsdaten können in der Benutzeroberfläche auf verschiedene Weise angezeigt werden:
1. **Nutzungsübersicht auf der Profilseite**:
- Gesamtkosten und Token-Nutzung
- Aufschlüsselung nach Modellen
2. **Detaillierte Nutzungsstatistiken**:
- Diagramme zur Visualisierung der Nutzung über die Zeit
- Vergleich der Nutzung verschiedener Modelle
3. **Konversationsdetails**:
- Anzeige der Token-Nutzung pro Konversation
- Kosten einzelner Nachrichten
## Hinweise zur Erweiterung
### Limits und Warnungen
Das System kann um folgende Funktionen erweitert werden:
- Nutzungslimits pro Benutzer oder Organisation
- Warnungen, wenn bestimmte Kostenschwellen überschritten werden
- Automatische Deaktivierung teurer Modelle bei Erreichen bestimmter Limits
### Export und Berichte
Die Token-Nutzungsdaten können für Berichte exportiert werden:
- Monatliche Kostenabrechnungen pro Benutzer
- Exportformate: CSV, PDF, JSON
- Automatisierte Berichterstellung
### Integration mit Abrechnungssystemen
Die Token-Nutzungsdaten können in Abrechnungssysteme integriert werden:
- Berechnung von Kosten für verschiedene Benutzer oder Teams
- Integration mit Stripe oder anderen Zahlungsabwicklern
- Implementierung von unterschiedlichen Preismodellen (Flatrate, Pay-per-Use, etc.)

View file

@ -0,0 +1,33 @@
-- SQL-Skript zum Hinzufügen der Archivfunktionalität
-- Fügt ein 'is_archived' Feld zur 'conversations' Tabelle hinzu
-- Prüfen, ob die Spalte existiert und füge sie hinzu, falls nicht
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'conversations'
AND column_name = 'is_archived'
) THEN
ALTER TABLE public.conversations
ADD COLUMN is_archived BOOLEAN DEFAULT false;
RAISE NOTICE 'Spalte is_archived zur conversations-Tabelle hinzugefügt.';
ELSE
RAISE NOTICE 'Spalte is_archived existiert bereits in der conversations-Tabelle.';
END IF;
END
$$;
-- Kommentar für die neue Spalte hinzufügen
COMMENT ON COLUMN conversations.is_archived IS 'Gibt an, ob die Konversation archiviert wurde.';
-- Index für schnellere Abfragen, da wir oft nach archivierten/nicht-archivierten Konversationen filtern werden
CREATE INDEX IF NOT EXISTS idx_conversations_is_archived ON conversations(is_archived);
-- Indices für die Kombination aus Benutzer-ID und Archivierungsstatus für optimierte Abfragen
CREATE INDEX IF NOT EXISTS idx_conversations_user_archived ON conversations(user_id, is_archived);
-- Stelle sicher, dass die RLS-Policies aktualisiert werden, um die neue Spalte zu berücksichtigen
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;

View file

@ -0,0 +1,19 @@
-- SQL zum Hinzufügen der Titel-Spalte zur Conversations-Tabelle
-- SCHRITT 1: Spalte zur Tabelle hinzufügen
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS title TEXT;
-- SCHRITT 2: Kommentar für Dokumentation hinzufügen
COMMENT ON COLUMN conversations.title IS 'KI-generierter Titel für die Konversation';
-- SCHRITT 3: Aktualisiere RLS-Policy (Row Level Security) für die neue Spalte
-- (Stelle sicher, dass Benutzer nur ihre eigenen Konversationen lesen/bearbeiten können)
DROP POLICY IF EXISTS "Users can update their own conversations" ON conversations;
CREATE POLICY "Users can update their own conversations"
ON conversations
FOR UPDATE USING (user_id = auth.uid());
-- SCHRITT 4: (Optional) Standardwerte für bestehende Konversationen setzen
UPDATE conversations
SET title = 'Frühere Konversation'
WHERE title IS NULL;

View file

@ -0,0 +1,84 @@
-- Überprüfe und erstelle Modelle in der Datenbank
-- 1. Überprüfe, ob die models-Tabelle existiert
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'models'
) THEN
-- Erstelle die models-Tabelle
CREATE TABLE public.models (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
parameters JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Erstelle einen Trigger für updated_at
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $func$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
CREATE TRIGGER set_models_updated_at
BEFORE UPDATE ON public.models
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
RAISE NOTICE 'Tabelle models wurde erstellt.';
ELSE
RAISE NOTICE 'Tabelle models existiert bereits.';
END IF;
END
$$;
-- 2. Überprüfe die Struktur der models-Tabelle und füge updated_at hinzu, falls es fehlt
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'models'
AND column_name = 'updated_at'
) THEN
ALTER TABLE public.models ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();
RAISE NOTICE 'Spalte updated_at zur models-Tabelle hinzugefügt.';
ELSE
RAISE NOTICE 'Spalte updated_at existiert bereits in der models-Tabelle.';
END IF;
END
$$;
-- 3. Füge Standard-Modelle ein
INSERT INTO public.models (id, name, description, parameters)
VALUES
('550e8400-e29b-41d4-a716-446655440000', 'GPT-O3-Mini', 'Azure OpenAI GPT-O3-Mini', '{"temperature": 0.7, "max_tokens": 800}'),
('550e8400-e29b-41d4-a716-446655440001', 'GPT-4', 'OpenAI GPT-4', '{"temperature": 0.7, "max_tokens": 1000}'),
('550e8400-e29b-41d4-a716-446655440002', 'GPT-3.5-Turbo', 'OpenAI GPT-3.5 Turbo', '{"temperature": 0.7, "max_tokens": 800}'),
('550e8400-e29b-41d4-a716-446655440003', 'Claude 3', 'Anthropic Claude 3', '{"temperature": 0.7, "max_tokens": 1000}')
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
parameters = EXCLUDED.parameters;
-- 4. Aktiviere RLS für die models-Tabelle
ALTER TABLE public.models ENABLE ROW LEVEL SECURITY;
-- 5. Erstelle eine Richtlinie für das Lesen von Modellen
-- Alle authentifizierten Benutzer dürfen Modelle sehen
DROP POLICY IF EXISTS models_select_policy ON models;
CREATE POLICY models_select_policy
ON models
FOR SELECT
TO authenticated
USING (true);
-- 6. Überprüfe, ob die Modelle existieren
SELECT id, name, description FROM public.models;

View file

@ -0,0 +1,66 @@
-- Skript zur Überprüfung und Konfiguration der Azure OpenAI-Einstellungen in der Datenbank
-- 1. Erstelle eine Tabelle für die Anwendungseinstellungen, falls sie noch nicht existiert
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'app_settings'
) THEN
CREATE TABLE public.app_settings (
id SERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value TEXT,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Erstelle einen Trigger für updated_at
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $func$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
CREATE TRIGGER set_app_settings_updated_at
BEFORE UPDATE ON public.app_settings
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
RAISE NOTICE 'Tabelle app_settings wurde erstellt.';
ELSE
RAISE NOTICE 'Tabelle app_settings existiert bereits.';
END IF;
END
$$;
-- 2. Füge Azure OpenAI-Einstellungen hinzu oder aktualisiere sie
INSERT INTO public.app_settings (key, value, description)
VALUES
('AZURE_OPENAI_ENDPOINT', 'https://memoroseopenai.openai.azure.com', 'Azure OpenAI API Endpoint'),
('AZURE_OPENAI_DEPLOYMENT', 'gpt-o3-mini-se', 'Azure OpenAI Deployment Name'),
('AZURE_OPENAI_API_VERSION', '2024-12-01-preview', 'Azure OpenAI API Version'),
('AZURE_OPENAI_API_KEY', '3082103c9b0d4270a795686ccaa89921', 'Azure OpenAI API Key')
ON CONFLICT (key) DO UPDATE SET
value = EXCLUDED.value,
description = EXCLUDED.description,
updated_at = NOW();
-- 3. Aktiviere RLS für die app_settings-Tabelle
ALTER TABLE public.app_settings ENABLE ROW LEVEL SECURITY;
-- 4. Erstelle eine Richtlinie für das Lesen von Einstellungen
-- Alle authentifizierten Benutzer dürfen Einstellungen sehen
DROP POLICY IF EXISTS app_settings_select_policy ON app_settings;
CREATE POLICY app_settings_select_policy
ON app_settings
FOR SELECT
TO authenticated
USING (true);
-- 5. Überprüfe, ob die Einstellungen existieren
SELECT key, value, description FROM public.app_settings;

View file

@ -0,0 +1,31 @@
-- Diese SQL-Funktion zum Löschen eines Dokuments erstellt eine sichere Methode
-- zum Löschen über RPC, was besser gegen Datenbank-Caching und Race-Conditions ist
-- Führen Sie dieses SQL im Supabase SQL-Editor aus
CREATE OR REPLACE FUNCTION delete_document_by_id(document_id uuid)
RETURNS boolean AS $$
DECLARE
success boolean;
affected_rows int;
BEGIN
-- Führe die eigentliche Löschung durch
DELETE FROM documents
WHERE id = document_id
AND conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
);
-- Speichere die Anzahl der betroffenen Zeilen
GET DIAGNOSTICS affected_rows = ROW_COUNT;
-- Setze den Erfolgsstatus basierend auf der Anzahl der gelöschten Zeilen
success := affected_rows > 0;
-- Gib das Ergebnis zurück (true wenn erfolgreich, false wenn nicht)
RETURN success;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Diese Funktion kann dann mit dem folgenden JavaScript aufgerufen werden:
-- const { data, error } = await supabase.rpc('delete_document_by_id', { document_id: 'uuid-here' });

View file

@ -0,0 +1,66 @@
-- Erstelle die Tabelle für Vorlagen
CREATE TABLE IF NOT EXISTS templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
system_prompt TEXT NOT NULL,
model_id UUID REFERENCES models(id),
color TEXT DEFAULT '#0A84FF',
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- Kommentare für Dokumentation
COMMENT ON TABLE templates IS 'Vorlagen für vordefinierte Chat-Prompts';
COMMENT ON COLUMN templates.name IS 'Name der Vorlage';
COMMENT ON COLUMN templates.description IS 'Beschreibung der Vorlage';
COMMENT ON COLUMN templates.system_prompt IS 'System-Prompt für die KI';
COMMENT ON COLUMN templates.model_id IS 'Das bevorzugte Modell für diese Vorlage (optional)';
COMMENT ON COLUMN templates.color IS 'Farbcode für die Darstellung in der UI';
COMMENT ON COLUMN templates.is_default IS 'Gibt an, ob diese Vorlage als Standard verwendet werden soll';
-- Row Level Security
ALTER TABLE templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view their own templates" ON templates
FOR SELECT USING (user_id = auth.uid());
CREATE POLICY "Users can create their own templates" ON templates
FOR INSERT WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update their own templates" ON templates
FOR UPDATE USING (user_id = auth.uid());
CREATE POLICY "Users can delete their own templates" ON templates
FOR DELETE USING (user_id = auth.uid());
-- Beispiel-Vorlagen
INSERT INTO templates (user_id, name, description, system_prompt, color, is_default)
VALUES (
(SELECT id FROM auth.users LIMIT 1), -- Erste Benutzer-ID
'Persönlicher Assistent',
'Allgemeiner Assistent, der bei vielfältigen Aufgaben hilft',
'Du bist ein hilfreicher Assistent. Antworte präzise und freundlich auf alle Fragen des Nutzers. Wenn du etwas nicht weißt, gib an, dass du dir unsicher bist, statt zu spekulieren.',
'#0A84FF',
true
);
INSERT INTO templates (user_id, name, description, system_prompt, color)
VALUES (
(SELECT id FROM auth.users LIMIT 1), -- Erste Benutzer-ID
'Kreativer Schreibpartner',
'Hilft beim Brainstorming und der Entwicklung kreativer Ideen',
'Du bist ein kreativer Schreibpartner. Hilf dem Nutzer bei der Entwicklung von Ideen für Geschichten, Charaktere, Szenarien und Dialoge. Sei fantasievoll und inspirierend. Schlage neue Richtungen vor, wenn der Nutzer feststeckt.',
'#FF375F'
);
INSERT INTO templates (user_id, name, description, system_prompt, color)
VALUES (
(SELECT id FROM auth.users LIMIT 1), -- Erste Benutzer-ID
'Technischer Berater',
'Unterstützt bei technischen Fragen und Programmierung',
'Du bist ein technischer Berater mit Expertenwissen in Programmierung, Softwareentwicklung und IT. Erkläre technische Konzepte verständlich, gib Code-Beispiele wenn nötig und biete praktische Lösungen für technische Probleme. Wenn Code bereitgestellt wird, analysiere ihn gründlich vor der Antwort.',
'#32D74B'
);

View file

@ -0,0 +1,144 @@
-- Funktionen zum Abfragen der Token-Nutzung
-- 1. Funktion zum Abrufen der Token-Nutzung eines Benutzers, gruppiert nach Modell
CREATE OR REPLACE FUNCTION get_user_model_usage(user_id UUID)
RETURNS TABLE (
model_id UUID,
model_name TEXT,
total_prompt_tokens BIGINT,
total_completion_tokens BIGINT,
total_tokens BIGINT,
total_cost DECIMAL(10, 6)
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
SELECT
ul.model_id,
m.name AS model_name,
SUM(ul.prompt_tokens)::BIGINT AS total_prompt_tokens,
SUM(ul.completion_tokens)::BIGINT AS total_completion_tokens,
SUM(ul.total_tokens)::BIGINT AS total_tokens,
SUM(ul.estimated_cost)::DECIMAL(10, 6) AS total_cost
FROM
usage_logs ul
JOIN
models m ON ul.model_id = m.id
WHERE
ul.user_id = get_user_model_usage.user_id
GROUP BY
ul.model_id, m.name
ORDER BY
total_cost DESC;
END;
$$;
-- 2. Funktion zum Abrufen der Token-Nutzung eines Benutzers nach Zeitraum
CREATE OR REPLACE FUNCTION get_user_usage_by_period(user_id UUID, period TEXT)
RETURNS TABLE (
time_period TEXT,
total_tokens BIGINT,
total_cost DECIMAL(10, 6)
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF period = 'day' THEN
RETURN QUERY
SELECT
TO_CHAR(ul.created_at, 'YYYY-MM-DD') AS time_period,
SUM(ul.total_tokens)::BIGINT AS total_tokens,
SUM(ul.estimated_cost)::DECIMAL(10, 6) AS total_cost
FROM
usage_logs ul
WHERE
ul.user_id = get_user_usage_by_period.user_id
GROUP BY
time_period
ORDER BY
time_period DESC;
ELSIF period = 'month' THEN
RETURN QUERY
SELECT
TO_CHAR(ul.created_at, 'YYYY-MM') AS time_period,
SUM(ul.total_tokens)::BIGINT AS total_tokens,
SUM(ul.estimated_cost)::DECIMAL(10, 6) AS total_cost
FROM
usage_logs ul
WHERE
ul.user_id = get_user_usage_by_period.user_id
GROUP BY
time_period
ORDER BY
time_period DESC;
ELSIF period = 'year' THEN
RETURN QUERY
SELECT
TO_CHAR(ul.created_at, 'YYYY') AS time_period,
SUM(ul.total_tokens)::BIGINT AS total_tokens,
SUM(ul.estimated_cost)::DECIMAL(10, 6) AS total_cost
FROM
usage_logs ul
WHERE
ul.user_id = get_user_usage_by_period.user_id
GROUP BY
time_period
ORDER BY
time_period DESC;
ELSE
-- Fallback auf tägliche Ansicht
RETURN QUERY
SELECT
TO_CHAR(ul.created_at, 'YYYY-MM-DD') AS time_period,
SUM(ul.total_tokens)::BIGINT AS total_tokens,
SUM(ul.estimated_cost)::DECIMAL(10, 6) AS total_cost
FROM
usage_logs ul
WHERE
ul.user_id = get_user_usage_by_period.user_id
GROUP BY
time_period
ORDER BY
time_period DESC;
END IF;
END;
$$;
-- 3. Funktion zum Abrufen der Token-Nutzung einer bestimmten Konversation
CREATE OR REPLACE FUNCTION get_conversation_usage(conversation_id UUID)
RETURNS TABLE (
message_id UUID,
created_at TIMESTAMP WITH TIME ZONE,
prompt_tokens BIGINT,
completion_tokens BIGINT,
total_tokens BIGINT,
estimated_cost DECIMAL(10, 6)
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
SELECT
ul.message_id,
ul.created_at,
ul.prompt_tokens::BIGINT,
ul.completion_tokens::BIGINT,
ul.total_tokens::BIGINT,
ul.estimated_cost::DECIMAL(10, 6)
FROM
usage_logs ul
WHERE
ul.conversation_id = get_conversation_usage.conversation_id
ORDER BY
ul.created_at;
END;
$$;
-- Erteile Berechtigungen für die Funktionen
GRANT EXECUTE ON FUNCTION get_user_model_usage TO authenticated;
GRANT EXECUTE ON FUNCTION get_user_usage_by_period TO authenticated;
GRANT EXECUTE ON FUNCTION get_conversation_usage TO authenticated;

View file

@ -0,0 +1,122 @@
-- Erstelle eine neue Tabelle für die Token-Nutzung und Kostenerfassung
-- 1. Überprüfe, ob die usage_logs-Tabelle existiert
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'usage_logs'
) THEN
-- Erstelle die usage_logs-Tabelle
CREATE TABLE public.usage_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id),
model_id UUID REFERENCES models(id),
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
estimated_cost DECIMAL(10, 6) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
RAISE NOTICE 'Tabelle usage_logs wurde erstellt.';
ELSE
RAISE NOTICE 'Tabelle usage_logs existiert bereits.';
END IF;
END
$$;
-- 2. Aktiviere RLS für die usage_logs-Tabelle
ALTER TABLE public.usage_logs ENABLE ROW LEVEL SECURITY;
-- 3. Erstelle RLS-Richtlinien für usage_logs
-- Benutzer können nur ihre eigenen Nutzungsdaten sehen
DROP POLICY IF EXISTS usage_logs_select_policy ON usage_logs;
CREATE POLICY usage_logs_select_policy
ON usage_logs
FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- Nur über die Anwendung dürfen Einträge erstellt werden
DROP POLICY IF EXISTS usage_logs_insert_policy ON usage_logs;
CREATE POLICY usage_logs_insert_policy
ON usage_logs
FOR INSERT
TO authenticated
WITH CHECK (user_id = auth.uid());
-- 4. Erweitere das Modell-Schema um Kosteninformationen
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'models'
AND column_name = 'cost_settings'
) THEN
ALTER TABLE public.models ADD COLUMN cost_settings JSONB DEFAULT '{"prompt_per_1k_tokens": 0.0001, "completion_per_1k_tokens": 0.0002}'::jsonb;
RAISE NOTICE 'Spalte cost_settings zur models-Tabelle hinzugefügt.';
ELSE
RAISE NOTICE 'Spalte cost_settings existiert bereits in der models-Tabelle.';
END IF;
END
$$;
-- 5. Aktualisiere die vorhandenen Modelle mit Kosteninformationen
UPDATE public.models
SET cost_settings = jsonb_build_object(
'prompt_per_1k_tokens', CASE
WHEN name LIKE '%GPT-O3-Mini%' THEN 0.0001
WHEN name LIKE '%GPT-4o-mini%' THEN 0.0001
WHEN name LIKE '%GPT-4o%' THEN 0.003
WHEN name LIKE '%GPT-4%' THEN 0.003
WHEN name LIKE '%GPT-3.5%' THEN 0.0001
WHEN name LIKE '%Claude%' THEN 0.0008
ELSE 0.0001
END,
'completion_per_1k_tokens', CASE
WHEN name LIKE '%GPT-O3-Mini%' THEN 0.0002
WHEN name LIKE '%GPT-4o-mini%' THEN 0.0002
WHEN name LIKE '%GPT-4o%' THEN 0.006
WHEN name LIKE '%GPT-4%' THEN 0.006
WHEN name LIKE '%GPT-3.5%' THEN 0.0002
WHEN name LIKE '%Claude%' THEN 0.0024
ELSE 0.0002
END
)
WHERE cost_settings IS NULL OR cost_settings = '{}'::jsonb;
-- 6. Funktion zur Berechnung der Kosten
CREATE OR REPLACE FUNCTION calculate_token_cost(
p_prompt_tokens INTEGER,
p_completion_tokens INTEGER,
p_model_id UUID
) RETURNS DECIMAL(10, 6) AS $$
DECLARE
v_prompt_cost DECIMAL(10, 6);
v_completion_cost DECIMAL(10, 6);
v_cost_settings JSONB;
v_cost DECIMAL(10, 6);
BEGIN
-- Hole die Kosteneinstellungen für das angegebene Modell
SELECT cost_settings INTO v_cost_settings
FROM models
WHERE id = p_model_id;
-- Extrahiere die Kosten pro 1000 Token
v_prompt_cost := (v_cost_settings->>'prompt_per_1k_tokens')::DECIMAL;
v_completion_cost := (v_cost_settings->>'completion_per_1k_tokens')::DECIMAL;
-- Berechne die Gesamtkosten
v_cost := (p_prompt_tokens * v_prompt_cost + p_completion_tokens * v_completion_cost) / 1000;
RETURN ROUND(v_cost, 6);
END;
$$ LANGUAGE plpgsql;
-- 7. Berechtigung zum Ausführen der Funktion
GRANT EXECUTE ON FUNCTION calculate_token_cost TO authenticated;

View file

@ -0,0 +1,103 @@
-- Erstelle oder aktualisiere die users-Tabelle im public-Schema
-- Prüfe, ob die users-Tabelle bereits existiert
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'users'
) THEN
-- Erstelle die users-Tabelle
CREATE TABLE public.users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Erstelle einen Trigger für updated_at
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $func$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;
CREATE TRIGGER set_users_updated_at
BEFORE UPDATE ON public.users
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
RAISE NOTICE 'Tabelle users wurde erstellt.';
ELSE
RAISE NOTICE 'Tabelle users existiert bereits.';
END IF;
END
$$;
-- Erstelle eine Funktion, um Benutzer aus auth.users in public.users zu synchronisieren
CREATE OR REPLACE FUNCTION sync_user_after_auth_event()
RETURNS TRIGGER AS $sync_func$
BEGIN
IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') AND NEW.confirmed_at IS NOT NULL THEN
-- Füge den Benutzer in die public.users-Tabelle ein oder aktualisiere ihn
INSERT INTO public.users (id, email, name, created_at, updated_at)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'name', NEW.email),
NEW.created_at,
NEW.updated_at
)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
name = COALESCE(NEW.raw_user_meta_data->>'name', NEW.email),
updated_at = NOW();
END IF;
RETURN NEW;
END;
$sync_func$ LANGUAGE plpgsql SECURITY DEFINER;
-- Erstelle einen Trigger für die auth.users-Tabelle
DROP TRIGGER IF EXISTS sync_user_after_auth_event_trigger ON auth.users;
CREATE TRIGGER sync_user_after_auth_event_trigger
AFTER INSERT OR UPDATE ON auth.users
FOR EACH ROW
EXECUTE FUNCTION sync_user_after_auth_event();
-- Synchronisiere bestehende Benutzer
INSERT INTO public.users (id, email, name, created_at, updated_at)
SELECT
id,
email,
COALESCE(raw_user_meta_data->>'name', email) as name,
created_at,
updated_at
FROM auth.users
WHERE confirmed_at IS NOT NULL
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
name = COALESCE(EXCLUDED.name, users.name),
updated_at = NOW();
-- Aktiviere RLS für die users-Tabelle
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Erstelle eine Richtlinie für das Lesen von Benutzern
-- Benutzer dürfen nur ihre eigenen Daten sehen
CREATE POLICY users_select_policy
ON public.users
FOR SELECT
TO authenticated
USING (id = auth.uid());
-- Erstelle eine Richtlinie für das Aktualisieren von Benutzern
-- Benutzer dürfen nur ihre eigenen Daten aktualisieren
CREATE POLICY users_update_policy
ON public.users
FOR UPDATE
TO authenticated
USING (id = auth.uid());

View file

@ -0,0 +1,120 @@
-- Überprüfe und behebe Probleme mit der Conversations-Tabelle
-- 1. Überprüfe die Struktur der Conversations-Tabelle
DO $$
DECLARE
column_exists BOOLEAN;
BEGIN
-- Prüfe, ob die user_id-Spalte vom Typ UUID ist
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'conversations'
AND column_name = 'user_id'
AND data_type = 'uuid'
) INTO column_exists;
IF NOT column_exists THEN
RAISE NOTICE 'Die user_id-Spalte in der conversations-Tabelle ist nicht vom Typ UUID. Bitte überprüfe die Tabellendefinition.';
ELSE
RAISE NOTICE 'Die user_id-Spalte in der conversations-Tabelle ist korrekt vom Typ UUID.';
END IF;
-- Prüfe, ob es eine Unique-Constraint gibt, die Konflikte verursachen könnte
SELECT EXISTS (
SELECT FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = 'conversations'
AND constraint_type = 'UNIQUE'
) INTO column_exists;
IF column_exists THEN
RAISE NOTICE 'Es gibt eine Unique-Constraint in der conversations-Tabelle, die Konflikte verursachen könnte.';
ELSE
RAISE NOTICE 'Es gibt keine Unique-Constraint in der conversations-Tabelle.';
END IF;
END $$;
-- 2. Überprüfe den Foreign-Key-Constraint
DO $$
DECLARE
fk_exists BOOLEAN;
BEGIN
-- Prüfe, ob es einen Foreign-Key-Constraint auf die users-Tabelle gibt
SELECT EXISTS (
SELECT FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND tc.table_name = 'conversations'
AND kcu.column_name = 'user_id'
) INTO fk_exists;
IF fk_exists THEN
RAISE NOTICE 'Es gibt einen Foreign-Key-Constraint auf die user_id-Spalte in der conversations-Tabelle.';
ELSE
RAISE NOTICE 'Es gibt keinen Foreign-Key-Constraint auf die user_id-Spalte in der conversations-Tabelle.';
END IF;
END $$;
-- 3. Überprüfe, ob der angemeldete Benutzer in der users-Tabelle existiert
-- Ersetze 'DEINE_BENUTZER_ID' durch die tatsächliche Benutzer-ID
DO $$
DECLARE
user_exists BOOLEAN;
user_id_value UUID := auth.uid(); -- Aktuelle Benutzer-ID
BEGIN
-- Prüfe, ob der Benutzer in der users-Tabelle existiert
EXECUTE format('
SELECT EXISTS (
SELECT FROM public.users
WHERE id = %L
)', user_id_value) INTO user_exists;
IF user_exists THEN
RAISE NOTICE 'Der Benutzer mit der ID % existiert in der users-Tabelle.', user_id_value;
ELSE
RAISE NOTICE 'Der Benutzer mit der ID % existiert NICHT in der users-Tabelle.', user_id_value;
-- Füge den Benutzer manuell in die users-Tabelle ein
EXECUTE format('
INSERT INTO public.users (id, email, created_at, updated_at)
SELECT id, email, created_at, updated_at
FROM auth.users
WHERE id = %L
ON CONFLICT (id) DO NOTHING
', user_id_value);
RAISE NOTICE 'Der Benutzer wurde in die users-Tabelle eingefügt.';
END IF;
END $$;
-- 4. Überprüfe, ob die Synchronisierung zwischen auth.users und public.users funktioniert
DO $$
BEGIN
-- Prüfe, ob der Trigger für die Synchronisierung existiert
IF EXISTS (
SELECT FROM pg_trigger
WHERE tgname = 'sync_user_after_auth_event_trigger'
) THEN
RAISE NOTICE 'Der Trigger für die Synchronisierung zwischen auth.users und public.users existiert.';
ELSE
RAISE NOTICE 'Der Trigger für die Synchronisierung zwischen auth.users und public.users existiert NICHT.';
END IF;
END $$;
-- 5. Synchronisiere alle Benutzer aus auth.users in public.users
INSERT INTO public.users (id, email, name, created_at, updated_at)
SELECT
id,
email,
COALESCE(raw_user_meta_data->>'name', email) as name,
created_at,
updated_at
FROM auth.users
WHERE confirmed_at IS NOT NULL
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
name = COALESCE(EXCLUDED.name, users.name),
updated_at = NOW();

View file

@ -0,0 +1,46 @@
-- SQL-Skript, um die Nachrichtentabelle zu überprüfen und zu korrigieren
-- Überprüfe die aktuelle Struktur der messages-Tabelle
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM
information_schema.columns
WHERE
table_name = 'messages'
ORDER BY
ordinal_position;
-- Überprüfe Constraints für die messages-Tabelle
SELECT
tc.constraint_name,
tc.constraint_type,
kcu.column_name,
cc.check_clause
FROM
information_schema.table_constraints tc
JOIN
information_schema.constraint_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
LEFT JOIN
information_schema.check_constraints cc
ON tc.constraint_name = cc.constraint_name
WHERE
tc.table_name = 'messages';
-- Behebt das Problem mit der Check-Constraint in der messages-Tabelle
-- Entfernt die bestehende Check-Constraint und erstellt eine neue, die 'assistant' akzeptiert
DO $$
BEGIN
-- Entferne die bestehende Check-Constraint
ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_sender_check;
-- Erstelle eine neue Check-Constraint, die 'assistant' akzeptiert
ALTER TABLE messages
ADD CONSTRAINT messages_sender_check
CHECK (sender IN ('user', 'assistant', 'system'));
RAISE NOTICE 'Check-Constraint für die sender-Spalte in der messages-Tabelle wurde aktualisiert.';
END $$;

View file

@ -0,0 +1,27 @@
-- Überprüfe die aktuelle Struktur der models-Tabelle
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'models';
-- Füge die fehlenden Spalten hinzu, wenn sie nicht existieren
DO $$
BEGIN
-- Überprüfe, ob created_at existiert
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'models' AND column_name = 'created_at'
) THEN
ALTER TABLE models ADD COLUMN created_at TIMESTAMPTZ DEFAULT now();
END IF;
-- Überprüfe, ob updated_at existiert
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'models' AND column_name = 'updated_at'
) THEN
ALTER TABLE models ADD COLUMN updated_at TIMESTAMPTZ DEFAULT now();
END IF;
END
$$;

View file

@ -0,0 +1,157 @@
-- RLS-Richtlinien für die Conversations-Tabelle
-- Aktiviere RLS für die Conversations-Tabelle
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
-- Löschen vorhandener Richtlinien (falls vorhanden)
DROP POLICY IF EXISTS conversations_insert_policy ON conversations;
DROP POLICY IF EXISTS conversations_select_policy ON conversations;
DROP POLICY IF EXISTS conversations_update_policy ON conversations;
DROP POLICY IF EXISTS conversations_delete_policy ON conversations;
-- Erstelle eine Richtlinie für das Einfügen von Konversationen
-- Benutzer dürfen nur Konversationen für sich selbst erstellen
CREATE POLICY conversations_insert_policy
ON conversations
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Erstelle eine Richtlinie für das Lesen von Konversationen
-- Benutzer dürfen nur ihre eigenen Konversationen sehen
CREATE POLICY conversations_select_policy
ON conversations
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Erstelle eine Richtlinie für das Aktualisieren von Konversationen
-- Benutzer dürfen nur ihre eigenen Konversationen aktualisieren
CREATE POLICY conversations_update_policy
ON conversations
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id);
-- Erstelle eine Richtlinie für das Löschen von Konversationen
-- Benutzer dürfen nur ihre eigenen Konversationen löschen
CREATE POLICY conversations_delete_policy
ON conversations
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
-- RLS-Richtlinien für die Messages-Tabelle
-- Aktiviere RLS für die Messages-Tabelle
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Löschen vorhandener Richtlinien (falls vorhanden)
DROP POLICY IF EXISTS messages_insert_policy ON messages;
DROP POLICY IF EXISTS messages_select_policy ON messages;
DROP POLICY IF EXISTS messages_update_policy ON messages;
DROP POLICY IF EXISTS messages_delete_policy ON messages;
-- Erstelle eine Richtlinie für das Einfügen von Nachrichten
-- Benutzer dürfen nur Nachrichten zu ihren eigenen Konversationen hinzufügen
CREATE POLICY messages_insert_policy
ON messages
FOR INSERT
TO authenticated
WITH CHECK (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
-- Erstelle eine Richtlinie für das Lesen von Nachrichten
-- Benutzer dürfen nur Nachrichten aus ihren eigenen Konversationen sehen
CREATE POLICY messages_select_policy
ON messages
FOR SELECT
TO authenticated
USING (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
-- Erstelle eine Richtlinie für das Aktualisieren von Nachrichten
-- Benutzer dürfen nur Nachrichten aus ihren eigenen Konversationen aktualisieren
CREATE POLICY messages_update_policy
ON messages
FOR UPDATE
TO authenticated
USING (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
-- Erstelle eine Richtlinie für das Löschen von Nachrichten
-- Benutzer dürfen nur Nachrichten aus ihren eigenen Konversationen löschen
CREATE POLICY messages_delete_policy
ON messages
FOR DELETE
TO authenticated
USING (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
-- RLS-Richtlinien für die Models-Tabelle
-- Aktiviere RLS für die Models-Tabelle
ALTER TABLE models ENABLE ROW LEVEL SECURITY;
-- Löschen vorhandener Richtlinien (falls vorhanden)
DROP POLICY IF EXISTS models_select_policy ON models;
-- Erstelle eine Richtlinie für das Lesen von Modellen
-- Alle authentifizierten Benutzer dürfen Modelle sehen
CREATE POLICY models_select_policy
ON models
FOR SELECT
TO authenticated
USING (true);
-- Erstelle eine Bypass-Richtlinie für Anon-Benutzer, um Modelle zu sehen
-- Dies ist wichtig für die API-Route, die Modelle abruft
CREATE POLICY models_anon_select_policy
ON models
FOR SELECT
TO anon
USING (true);
-- RLS-Richtlinien für die Templates-Tabelle (falls vorhanden)
-- Aktiviere RLS für die Templates-Tabelle (nur wenn sie existiert)
DO $$
BEGIN
IF EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'templates'
) THEN
EXECUTE 'ALTER TABLE templates ENABLE ROW LEVEL SECURITY;';
-- Löschen vorhandener Richtlinien (falls vorhanden)
EXECUTE 'DROP POLICY IF EXISTS templates_select_policy ON templates;';
-- Erstelle eine Richtlinie für das Lesen von Templates
EXECUTE 'CREATE POLICY templates_select_policy
ON templates
FOR SELECT
TO authenticated
USING (true);';
-- Erstelle eine Bypass-Richtlinie für Anon-Benutzer, um Templates zu sehen
EXECUTE 'CREATE POLICY templates_anon_select_policy
ON templates
FOR SELECT
TO anon
USING (true);';
END IF;
END
$$;

View file

@ -0,0 +1,73 @@
-- Überprüfe und korrigiere die templates-Tabelle
-- 1. Prüfe, ob die templates-Tabelle existiert, erstelle sie falls nicht
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'templates'
) THEN
-- Erstelle die Tabelle für Vorlagen
CREATE TABLE public.templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
system_prompt TEXT NOT NULL,
initial_question TEXT,
model_id UUID REFERENCES models(id),
color TEXT DEFAULT '#0A84FF',
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
RAISE NOTICE 'Tabelle templates wurde erstellt.';
ELSE
RAISE NOTICE 'Tabelle templates existiert bereits.';
END IF;
END
$$;
-- 2. Prüfe, ob die color-Spalte existiert, füge sie hinzu falls nicht
DO $$
BEGIN
IF NOT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'templates'
AND column_name = 'color'
) THEN
ALTER TABLE public.templates ADD COLUMN color TEXT DEFAULT '#0A84FF';
RAISE NOTICE 'Spalte color zur templates-Tabelle hinzugefügt.';
ELSE
RAISE NOTICE 'Spalte color existiert bereits in der templates-Tabelle.';
END IF;
END
$$;
-- 3. Row Level Security
ALTER TABLE templates ENABLE ROW LEVEL SECURITY;
-- Lösche bestehende Policies, falls vorhanden
DROP POLICY IF EXISTS "Users can view their own templates" ON templates;
DROP POLICY IF EXISTS "Users can create their own templates" ON templates;
DROP POLICY IF EXISTS "Users can update their own templates" ON templates;
DROP POLICY IF EXISTS "Users can delete their own templates" ON templates;
-- Erstelle die Policies neu
CREATE POLICY "Users can view their own templates" ON templates
FOR SELECT USING (user_id = auth.uid());
CREATE POLICY "Users can create their own templates" ON templates
FOR INSERT WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update their own templates" ON templates
FOR UPDATE USING (user_id = auth.uid());
CREATE POLICY "Users can delete their own templates" ON templates
FOR DELETE USING (user_id = auth.uid());
-- 4. Zeige einige Beispieldaten an
SELECT id, name, description, color FROM templates LIMIT 5;

View file

@ -0,0 +1,115 @@
-- RLS-Richtlinien für die Conversations-Tabelle
-- Aktiviere RLS für die Conversations-Tabelle
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
-- Erstelle eine Richtlinie für das Einfügen von Konversationen
-- Benutzer dürfen nur Konversationen für sich selbst erstellen
CREATE POLICY conversations_insert_policy
ON conversations
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Erstelle eine Richtlinie für das Lesen von Konversationen
-- Benutzer dürfen nur ihre eigenen Konversationen sehen
CREATE POLICY conversations_select_policy
ON conversations
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Erstelle eine Richtlinie für das Aktualisieren von Konversationen
-- Benutzer dürfen nur ihre eigenen Konversationen aktualisieren
CREATE POLICY conversations_update_policy
ON conversations
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id);
-- Erstelle eine Richtlinie für das Löschen von Konversationen
-- Benutzer dürfen nur ihre eigenen Konversationen löschen
CREATE POLICY conversations_delete_policy
ON conversations
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
-- RLS-Richtlinien für die Messages-Tabelle
-- Aktiviere RLS für die Messages-Tabelle
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Erstelle eine Richtlinie für das Einfügen von Nachrichten
-- Benutzer dürfen nur Nachrichten zu ihren eigenen Konversationen hinzufügen
CREATE POLICY messages_insert_policy
ON messages
FOR INSERT
TO authenticated
WITH CHECK (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
-- Erstelle eine Richtlinie für das Lesen von Nachrichten
-- Benutzer dürfen nur Nachrichten aus ihren eigenen Konversationen sehen
CREATE POLICY messages_select_policy
ON messages
FOR SELECT
TO authenticated
USING (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
-- Erstelle eine Richtlinie für das Aktualisieren von Nachrichten
-- Benutzer dürfen nur Nachrichten aus ihren eigenen Konversationen aktualisieren
CREATE POLICY messages_update_policy
ON messages
FOR UPDATE
TO authenticated
USING (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
-- Erstelle eine Richtlinie für das Löschen von Nachrichten
-- Benutzer dürfen nur Nachrichten aus ihren eigenen Konversationen löschen
CREATE POLICY messages_delete_policy
ON messages
FOR DELETE
TO authenticated
USING (
conversation_id IN (
SELECT id FROM conversations WHERE user_id = auth.uid()
)
);
-- RLS-Richtlinien für die Models-Tabelle
-- Aktiviere RLS für die Models-Tabelle
ALTER TABLE models ENABLE ROW LEVEL SECURITY;
-- Erstelle eine Richtlinie für das Lesen von Modellen
-- Alle authentifizierten Benutzer dürfen Modelle sehen
CREATE POLICY models_select_policy
ON models
FOR SELECT
TO authenticated
USING (true);
-- RLS-Richtlinien für die Templates-Tabelle
-- Aktiviere RLS für die Templates-Tabelle
ALTER TABLE templates ENABLE ROW LEVEL SECURITY;
-- Erstelle eine Richtlinie für das Lesen von Templates
-- Alle authentifizierten Benutzer dürfen Templates sehen
CREATE POLICY templates_select_policy
ON templates
FOR SELECT
TO authenticated
USING (true);

View file

@ -0,0 +1,136 @@
// Skript zum Einrichten von Supabase-Funktionen und Tabellen
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import fs from 'fs';
// Lade Umgebungsvariablen aus .env
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Supabase-Client erstellen
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) {
console.error('Fehler: EXPO_PUBLIC_SUPABASE_URL und EXPO_PUBLIC_SUPABASE_ANON_KEY müssen in der .env-Datei definiert sein.');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
// Funktion zum Ausführen einer SQL-Datei
async function executeSqlFile(filePath) {
try {
const fullPath = join(__dirname, filePath);
console.log(`Führe SQL-Datei aus: ${fullPath}`);
if (!fs.existsSync(fullPath)) {
console.error(`Fehler: Datei ${fullPath} existiert nicht.`);
return false;
}
const query = fs.readFileSync(fullPath, 'utf8');
// Teile die Abfrage in einzelne Anweisungen auf
const statements = query.split(';').filter(stmt => stmt.trim() !== '');
for (const statement of statements) {
console.log(`Führe aus: ${statement.trim()}`);
const { error } = await supabase.rpc('execute_sql', { query: statement.trim() });
if (error) {
console.error('Fehler bei der Ausführung:', error.message);
// Fahre trotz Fehler fort
}
}
return true;
} catch (error) {
console.error('Fehler beim Lesen oder Ausführen der Datei:', error.message);
return false;
}
}
// Funktion zum Erstellen der execute_sql-Funktion
async function createExecuteSqlFunction() {
try {
console.log('Erstelle execute_sql-Funktion...');
const query = `
CREATE OR REPLACE FUNCTION execute_sql(query text)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
result JSONB;
BEGIN
EXECUTE 'SELECT jsonb_agg(row_to_json(t)) FROM (' || query || ') t' INTO result;
RETURN COALESCE(result, '[]'::jsonb);
EXCEPTION
WHEN OTHERS THEN
RAISE EXCEPTION 'SQL-Fehler: %', SQLERRM;
END;
$$;
`;
const { error } = await supabase.rpc('execute_sql', { query });
if (error) {
// Die Funktion existiert möglicherweise noch nicht, versuche direkte SQL-Ausführung
console.log('Versuche direkte SQL-Ausführung...');
const { error: directError } = await supabase
.from('_exec_sql')
.insert({ sql: query });
if (directError) {
console.error('Fehler beim Erstellen der execute_sql-Funktion:', directError.message);
return false;
}
}
console.log('execute_sql-Funktion erfolgreich erstellt.');
return true;
} catch (error) {
console.error('Unerwarteter Fehler:', error.message);
return false;
}
}
// Hauptfunktion
async function setupSupabase() {
console.log('Starte Supabase-Setup...');
// Erstelle die execute_sql-Funktion
const execSqlCreated = await createExecuteSqlFunction();
if (!execSqlCreated) {
console.log('Konnte execute_sql-Funktion nicht erstellen. Versuche trotzdem fortzufahren...');
}
// Führe die SQL-Dateien aus
console.log('Führe Supabase-Funktionen-Setup aus...');
await executeSqlFile('setup_supabase_functions.sql');
console.log('Führe Modell-Updates aus...');
await executeSqlFile('update_models.sql');
console.log('Richte RLS-Richtlinien ein...');
await executeSqlFile('setup_rls_policies.sql');
console.log('Supabase-Setup abgeschlossen.');
}
// Führe die Funktion aus
setupSupabase()
.catch(error => {
console.error('Unerwarteter Fehler:', error);
})
.finally(() => {
process.exit(0);
});

View file

@ -0,0 +1,52 @@
-- Erstelle eine Funktion zum Ausführen von SQL-Abfragen
CREATE OR REPLACE FUNCTION execute_sql(query text)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
result JSONB;
BEGIN
EXECUTE 'SELECT jsonb_agg(row_to_json(t)) FROM (' || query || ') t' INTO result;
RETURN COALESCE(result, '[]'::jsonb);
EXCEPTION
WHEN OTHERS THEN
RAISE EXCEPTION 'SQL-Fehler: %', SQLERRM;
END;
$$;
-- Erstelle eine Funktion zum Erstellen der models-Tabelle, falls sie nicht existiert
CREATE OR REPLACE FUNCTION create_models_table()
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
-- Prüfe, ob die Tabelle bereits existiert
IF NOT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'models'
) THEN
-- Erstelle die Tabelle
CREATE TABLE models (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
parameters JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Erstelle einen Trigger für updated_at
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON models
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
RAISE NOTICE 'Tabelle models wurde erstellt.';
ELSE
RAISE NOTICE 'Tabelle models existiert bereits.';
END IF;
END;
$$;

View file

@ -0,0 +1,176 @@
-- Enable Row Level Security for spaces tables
ALTER TABLE public.spaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.space_members ENABLE ROW LEVEL SECURITY;
-- RLS policies for spaces
-- Space owners can do everything with their spaces
CREATE POLICY spaces_owner_policy
ON public.spaces
TO authenticated
USING (owner_id = auth.uid());
-- Members can view spaces they belong to
CREATE POLICY spaces_member_select_policy
ON public.spaces
FOR SELECT
TO authenticated
USING (
id IN (
SELECT space_id
FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
);
-- RLS policies for space_members
-- Space owners can manage all members
CREATE POLICY space_members_owner_policy
ON public.space_members
TO authenticated
USING (
space_id IN (
SELECT id FROM public.spaces WHERE owner_id = auth.uid()
)
);
-- Space admins can manage members (except owners)
CREATE POLICY space_members_admin_policy
ON public.space_members
TO authenticated
USING (
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND role = 'admin' AND invitation_status = 'accepted'
)
AND role != 'owner'
);
-- Users can see which spaces they are members of
CREATE POLICY space_members_self_select_policy
ON public.space_members
FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- Users can accept/decline their own invitations
CREATE POLICY space_members_invitation_update_policy
ON public.space_members
FOR UPDATE
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (
user_id = auth.uid()
AND (OLD.invitation_status = 'pending')
AND (NEW.invitation_status IN ('accepted', 'declined'))
AND (OLD.role = NEW.role)
AND (OLD.space_id = NEW.space_id)
AND (OLD.user_id = NEW.user_id)
);
-- Update RLS policies for conversations
-- Modify existing policies to include space-based access
DROP POLICY IF EXISTS conversations_select_policy ON conversations;
CREATE POLICY conversations_select_policy
ON conversations
FOR SELECT
TO authenticated
USING (
user_id = auth.uid()
OR
(
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
)
);
-- Allow space members to create conversations in spaces they belong to
CREATE POLICY conversations_space_insert_policy
ON conversations
FOR INSERT
TO authenticated
WITH CHECK (
user_id = auth.uid()
AND
(
space_id IS NULL
OR
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
)
);
-- Allow updates to conversations in spaces based on role
DROP POLICY IF EXISTS conversations_update_policy ON conversations;
CREATE POLICY conversations_update_policy
ON conversations
FOR UPDATE
TO authenticated
USING (
user_id = auth.uid()
OR
(
space_id IN (
SELECT sm.space_id FROM public.space_members sm
WHERE sm.user_id = auth.uid()
AND sm.invitation_status = 'accepted'
AND sm.role IN ('owner', 'admin')
)
)
);
-- Allow deletion of conversations in spaces based on role
DROP POLICY IF EXISTS conversations_delete_policy ON conversations;
CREATE POLICY conversations_delete_policy
ON conversations
FOR DELETE
TO authenticated
USING (
user_id = auth.uid()
OR
(
space_id IN (
SELECT sm.space_id FROM public.space_members sm
WHERE sm.user_id = auth.uid()
AND sm.invitation_status = 'accepted'
AND sm.role IN ('owner', 'admin')
)
)
);
-- Helper function to check if a user has access to a space
CREATE OR REPLACE FUNCTION public.user_has_space_access(space_uuid UUID, role_level TEXT DEFAULT 'viewer')
RETURNS BOOLEAN AS $$
DECLARE
has_access BOOLEAN;
role_hierarchy TEXT[];
BEGIN
-- Define role hierarchy from highest to lowest
role_hierarchy := ARRAY['owner', 'admin', 'member', 'viewer'];
-- Find position of requested role in hierarchy
WITH role_positions AS (
SELECT
unnest(role_hierarchy) AS role,
row_number() OVER () AS position
)
SELECT EXISTS (
SELECT 1 FROM public.space_members sm
JOIN role_positions rp1 ON sm.role = rp1.role
JOIN role_positions rp2 ON rp2.role = role_level
WHERE sm.space_id = space_uuid
AND sm.user_id = auth.uid()
AND sm.invitation_status = 'accepted'
AND rp1.position <= rp2.position -- Check if user's role is at least the required level
) INTO has_access;
RETURN has_access;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

View file

@ -0,0 +1,45 @@
-- Create spaces table
CREATE TABLE IF NOT EXISTS public.spaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
owner_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_archived BOOLEAN DEFAULT false
);
-- Add comments for documentation
COMMENT ON TABLE public.spaces IS 'Collaborative spaces for organizing conversations';
COMMENT ON COLUMN spaces.name IS 'Name of the space';
COMMENT ON COLUMN spaces.description IS 'Optional description of the space';
COMMENT ON COLUMN spaces.owner_id IS 'User ID of the space owner';
COMMENT ON COLUMN spaces.is_archived IS 'Indicates whether the space is archived';
-- Create space_members table with roles/permissions
CREATE TABLE IF NOT EXISTS public.space_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
invitation_status TEXT NOT NULL DEFAULT 'pending' CHECK (invitation_status IN ('pending', 'accepted', 'declined')),
invited_by UUID REFERENCES public.users(id),
invited_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
joined_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(space_id, user_id)
);
-- Add comments for space_members
COMMENT ON TABLE public.space_members IS 'Members of collaborative spaces with defined roles';
COMMENT ON COLUMN space_members.role IS 'Role of the user in the space (owner, admin, member, viewer)';
COMMENT ON COLUMN space_members.invitation_status IS 'Status of the invitation (pending, accepted, declined)';
-- Modify conversations table to add space_id
ALTER TABLE public.conversations
ADD COLUMN IF NOT EXISTS space_id UUID REFERENCES public.spaces(id) ON DELETE SET NULL;
-- Create indexes for faster queries
CREATE INDEX IF NOT EXISTS idx_conversations_space_id ON conversations(space_id);
CREATE INDEX IF NOT EXISTS idx_conversations_space_user ON conversations(space_id, user_id);

View file

@ -0,0 +1,97 @@
-- Create updated_at trigger for spaces
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply updated_at trigger to spaces table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'set_spaces_updated_at'
) THEN
CREATE TRIGGER set_spaces_updated_at
BEFORE UPDATE ON public.spaces
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
END IF;
END
$$;
-- Apply updated_at trigger to space_members table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'set_space_members_updated_at'
) THEN
CREATE TRIGGER set_space_members_updated_at
BEFORE UPDATE ON public.space_members
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
END IF;
END
$$;
-- Automatically add space owner as member with owner role
CREATE OR REPLACE FUNCTION add_owner_to_space_members()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.space_members (
space_id,
user_id,
role,
invitation_status,
joined_at
)
VALUES (
NEW.id,
NEW.owner_id,
'owner',
'accepted',
NOW()
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply owner trigger to spaces table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'add_owner_to_space_members_trigger'
) THEN
CREATE TRIGGER add_owner_to_space_members_trigger
AFTER INSERT ON public.spaces
FOR EACH ROW
EXECUTE FUNCTION add_owner_to_space_members();
END IF;
END
$$;
-- Update space modification timestamp when members are added/changed
CREATE OR REPLACE FUNCTION update_space_timestamp_on_member_change()
RETURNS TRIGGER AS $$
BEGIN
UPDATE public.spaces
SET updated_at = NOW()
WHERE id = NEW.space_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply space timestamp update trigger if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'update_space_timestamp_trigger'
) THEN
CREATE TRIGGER update_space_timestamp_trigger
AFTER INSERT OR UPDATE ON public.space_members
FOR EACH ROW
EXECUTE FUNCTION update_space_timestamp_on_member_change();
END IF;
END
$$;

View file

@ -0,0 +1,86 @@
-- Drop problematic policies that cause infinite recursion
DROP POLICY IF EXISTS space_members_owner_policy ON space_members;
DROP POLICY IF EXISTS space_members_admin_policy ON space_members;
DROP POLICY IF EXISTS space_members_self_select_policy ON space_members;
DROP POLICY IF EXISTS space_members_invitation_update_policy ON space_members;
DROP POLICY IF EXISTS conversations_select_policy ON conversations;
DROP POLICY IF EXISTS conversations_space_insert_policy ON conversations;
DROP POLICY IF EXISTS conversations_update_policy ON conversations;
DROP POLICY IF EXISTS conversations_delete_policy ON conversations;
-- Recreate RLS policies for space_members (simplified to avoid recursion)
CREATE POLICY space_members_owner_policy
ON public.space_members
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.spaces
WHERE id = space_id AND owner_id = auth.uid()
)
);
-- Users can see which spaces they are members of
CREATE POLICY space_members_self_select_policy
ON public.space_members
FOR SELECT
TO authenticated
USING (user_id = auth.uid());
-- Users can accept/decline their own invitations
CREATE POLICY space_members_invitation_update_policy
ON public.space_members
FOR UPDATE
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (
user_id = auth.uid()
AND invitation_status = 'pending'
);
-- Create simplified policies for conversations
-- Allow users to see their own conversations or shared with them
CREATE POLICY conversations_select_policy
ON conversations
FOR SELECT
TO authenticated
USING (
user_id = auth.uid()
OR
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
);
-- Allow users to create conversations in spaces they belong to
CREATE POLICY conversations_space_insert_policy
ON conversations
FOR INSERT
TO authenticated
WITH CHECK (
user_id = auth.uid()
AND
(
space_id IS NULL
OR
space_id IN (
SELECT space_id FROM public.space_members
WHERE user_id = auth.uid() AND invitation_status = 'accepted'
)
)
);
-- Allow users to update their own conversations
CREATE POLICY conversations_update_policy
ON conversations
FOR UPDATE
TO authenticated
USING (user_id = auth.uid());
-- Allow users to delete their own conversations
CREATE POLICY conversations_delete_policy
ON conversations
FOR DELETE
TO authenticated
USING (user_id = auth.uid());

View file

@ -0,0 +1,69 @@
-- Completely drop ALL RLS policies for the affected tables
DROP POLICY IF EXISTS spaces_owner_policy ON spaces;
DROP POLICY IF EXISTS spaces_member_select_policy ON spaces;
DROP POLICY IF EXISTS space_members_owner_policy ON space_members;
DROP POLICY IF EXISTS space_members_admin_policy ON space_members;
DROP POLICY IF EXISTS space_members_self_select_policy ON space_members;
DROP POLICY IF EXISTS space_members_invitation_update_policy ON space_members;
DROP POLICY IF EXISTS conversations_select_policy ON conversations;
DROP POLICY IF EXISTS conversations_space_insert_policy ON conversations;
DROP POLICY IF EXISTS conversations_update_policy ON conversations;
DROP POLICY IF EXISTS conversations_delete_policy ON conversations;
-- Create minimal basic policies for spaces
CREATE POLICY spaces_select_policy
ON public.spaces
FOR SELECT
TO authenticated
USING (true); -- Allow all users to see all spaces for now
CREATE POLICY spaces_insert_policy
ON public.spaces
FOR INSERT
TO authenticated
WITH CHECK (owner_id = auth.uid()); -- Only allow users to create spaces where they are the owner
-- Create minimal basic policies for space_members
CREATE POLICY space_members_select_policy
ON public.space_members
FOR SELECT
TO authenticated
USING (true); -- Allow all users to see all space members for now
CREATE POLICY space_members_insert_policy
ON public.space_members
FOR INSERT
TO authenticated
WITH CHECK (true); -- Allow all insertions for now
CREATE POLICY space_members_update_policy
ON public.space_members
FOR UPDATE
TO authenticated
USING (true) -- Allow all updates for now
WITH CHECK (true);
-- Revert conversations back to simple user-based policies
CREATE POLICY conversations_select_policy
ON conversations
FOR SELECT
TO authenticated
USING (user_id = auth.uid()); -- Only see your own conversations
CREATE POLICY conversations_insert_policy
ON conversations
FOR INSERT
TO authenticated
WITH CHECK (user_id = auth.uid()); -- Only create your own conversations
CREATE POLICY conversations_update_policy
ON conversations
FOR UPDATE
TO authenticated
USING (user_id = auth.uid()); -- Only update your own conversations
CREATE POLICY conversations_delete_policy
ON conversations
FOR DELETE
TO authenticated
USING (user_id = auth.uid()); -- Only delete your own conversations

View file

@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* This script sets up the spaces feature by running the necessary SQL scripts
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { createClient } = require('@supabase/supabase-js');
// Get environment variables
const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY;
if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) {
console.error('Error: SUPABASE_URL and SUPABASE_SERVICE_KEY environment variables must be set');
process.exit(1);
}
// Create Supabase client
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
async function executeSQL(filename) {
try {
const filePath = path.join(__dirname, filename);
const sql = fs.readFileSync(filePath, 'utf8');
// Split the SQL file by semicolons to get individual statements
const statements = sql
.split(';')
.map(statement => statement.trim())
.filter(statement => statement.length > 0);
console.log(`Executing ${statements.length} statements from ${filename}...`);
for (const statement of statements) {
const { error } = await supabase.rpc('exec_sql', { sql: statement });
if (error) {
console.error(`Error executing statement:`, error);
console.error(`Statement was: ${statement.substring(0, 100)}...`);
}
}
console.log(`✅ Successfully executed ${filename}`);
return true;
} catch (error) {
console.error(`❌ Error executing ${filename}:`, error);
return false;
}
}
async function main() {
console.log('Setting up spaces feature...');
// Run the SQL scripts in the correct order
const scripts = [
'create_spaces_tables.sql',
'create_spaces_triggers.sql',
'create_spaces_rls.sql'
];
for (const script of scripts) {
const success = await executeSQL(script);
if (!success) {
console.error(`Failed to execute ${script}. Aborting.`);
process.exit(1);
}
}
console.log('✅ Spaces feature setup complete!');
}
main().catch(err => {
console.error('Unhandled error:', err);
process.exit(1);
});

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