refactor: restructure

monorepo with apps/ and services/
  directories
This commit is contained in:
Wuesteon 2025-11-26 03:03:24 +01:00
parent 25824ed0ac
commit ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions

122
apps/chat/CLAUDE.md Normal file
View file

@ -0,0 +1,122 @@
# Chat Project Guide
## Project Structure
```
apps/chat/
├── apps/
│ ├── backend/ # NestJS API server (@chat/backend)
│ ├── landing/ # Astro marketing landing page (@chat/landing)
│ ├── web/ # SvelteKit web application (@chat/web)
│ └── mobile/ # Expo/React Native mobile app (@chat/mobile)
├── 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 (apps/chat/apps/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,395 @@
# ✅ Mana Core Auth Integration - COMPLETE
**Date:** 2025-11-25
**Status:** 🎉 All code changes implemented
**Project:** Chat (Backend, Web, Mobile)
---
## 🎯 Summary
The Chat project has been **fully migrated** from Supabase Auth to **Mana Core Auth**! All three apps (backend, web, mobile) now use the centralized authentication system with built-in credit management.
---
## ✅ What Was Done
### 1. **Updated `@manacore/shared-auth` Package**
**Location:** `/packages/shared-auth/src/core/authService.ts`
**Changes:**
- Updated API endpoints to match Mana Core Auth (`/api/v1/auth/*`)
- Fixed login response handling (`accessToken` instead of `appToken`)
- Fixed signup flow (register then login separately)
- Updated refresh token endpoint
- Updated credits balance endpoint
**Status:** Package is now 100% compatible with Mana Core Auth API
---
### 2. **Chat Backend Integration**
**Files Modified:**
- ✅ `chat/backend/src/common/guards/jwt-auth.guard.ts` (NEW)
- ✅ `chat/backend/src/common/decorators/current-user.decorator.ts` (NEW)
- ✅ `chat/backend/src/chat/chat.controller.ts`
- ✅ `chat/backend/src/chat/chat.service.ts`
- ✅ `chat/backend/src/conversation/conversation.controller.ts`
- ✅ `chat/backend/.env.example`
**Changes:**
- Created JWT Auth Guard that validates tokens with Mana Core Auth
- Created CurrentUser decorator to inject user data into controllers
- Updated all controllers to use JwtAuthGuard
- Removed userId from request body (now extracted from JWT)
- Added MANA_CORE_AUTH_URL environment variable
- Changed PORT from 3001 to 3002 (to avoid conflict with auth service)
**Key Features:**
- All endpoints now protected with JWT validation
- User context automatically injected via @CurrentUser decorator
- Token validation happens via Mana Core Auth API
- Proper error handling for invalid/expired tokens
---
### 3. **Chat Web App Integration**
**Files Modified:**
- ✅ `chat/apps/web/src/lib/stores/auth.svelte.ts`
- ✅ `chat/apps/web/.env.example`
**Changes:**
- Completely rewrote auth store to use `@manacore/shared-auth`
- Removed Supabase auth dependencies
- Added `initializeWebAuth()` initialization
- Added `getCredits()` method for credit balance
- Added `getAccessToken()` method for API calls
- Added MANA_CORE_AUTH_URL environment variable
**API Compatibility:**
- Same method signatures as before (signIn, signUp, signOut, resetPassword)
- Minimal breaking changes for existing code
- Additional methods: `getCredits()`, `getAccessToken()`
---
### 4. **Chat Mobile App Integration**
**Files Modified:**
- ✅ `chat/apps/mobile/context/AuthProvider.tsx`
- ✅ `chat/apps/mobile/.env.example`
**Changes:**
- Rewrote AuthProvider to use `@manacore/shared-auth`
- Created SecureStore adapter for token storage
- Created React Native device adapter
- Created React Native network adapter
- Removed Supabase auth dependencies
- Added MANA_CORE_AUTH_URL environment variable
**Key Features:**
- Tokens stored securely in Expo SecureStore
- Device ID generated and persisted
- Same API as before (useAuth hook remains unchanged)
- Auto sign-in after successful signup
---
## 📝 Configuration Changes
### Backend `.env`
```env
# OLD (Remove):
# SUPABASE_URL=...
# SUPABASE_SERVICE_KEY=...
# PORT=3001
# NEW (Add):
MANA_CORE_AUTH_URL=http://localhost:3001
PORT=3002
# Keep (for database):
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-key-here
```
### Web App `.env`
```env
# OLD (Remove):
# PUBLIC_SUPABASE_URL=...
# PUBLIC_SUPABASE_ANON_KEY=...
# PUBLIC_BACKEND_URL=http://localhost:3001
# NEW (Add):
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
PUBLIC_BACKEND_URL=http://localhost:3002
# Keep (for database):
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
```
### Mobile App `.env`
```env
# OLD (Remove):
# EXPO_PUBLIC_SUPABASE_URL=...
# EXPO_PUBLIC_SUPABASE_ANON_KEY=...
# EXPO_PUBLIC_BACKEND_URL=http://localhost:3001
# NEW (Add):
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
# Keep (for database):
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
```
---
## 🚀 How to Run
### 1. Start Mana Core Auth (Terminal 1)
```bash
cd mana-core-auth
cp .env.example .env
# Edit .env and add JWT keys (see mana-core-auth/QUICKSTART.md)
pnpm start:dev
```
Service runs on: `http://localhost:3001`
### 2. Start Chat Backend (Terminal 2)
```bash
cd chat/backend
cp .env.example .env
# Edit .env:
# - Add MANA_CORE_AUTH_URL=http://localhost:3001
# - Change PORT=3002
pnpm start:dev
```
Service runs on: `http://localhost:3002`
### 3. Start Web App (Terminal 3)
```bash
cd chat/apps/web
cp .env.example .env
# Edit .env:
# - Add PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# - Change PUBLIC_BACKEND_URL=http://localhost:3002
pnpm dev
```
App runs on: `http://localhost:5173`
### 4. Start Mobile App (Terminal 4)
```bash
cd chat/apps/mobile
cp .env.example .env
# Edit .env:
# - Add EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# - Change EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
pnpm dev
```
---
## 🧪 Testing Checklist
### Backend
- [ ] Start backend on port 3002
- [ ] Try accessing `/api/chat/models` without token → Should return 401
- [ ] Login via Mana Core Auth
- [ ] Access `/api/chat/models` with token → Should work
- [ ] Access `/api/conversations` with token → Should work
### Web App
- [ ] Go to `/login`
- [ ] Register new user
- [ ] Should redirect and auto-login
- [ ] Check user is authenticated
- [ ] Try protected routes
- [ ] Logout
- [ ] Try protected routes again → Should redirect to login
### Mobile App
- [ ] Open app
- [ ] Register new user
- [ ] Should auto-login
- [ ] Check chat functionality works
- [ ] Logout
- [ ] Login again with same credentials
---
## 💡 New Features Available
### Credit System (Built-in)
All users now have access to the credit system:
```typescript
// Web App
const credits = await authStore.getCredits();
console.log(credits); // { credits: 150, maxCreditLimit: 1000, userId: "..." }
// Mobile App (need to add this method to AuthProvider if needed)
const credits = await authService.getUserCredits();
```
**Default Credits:**
- Signup bonus: 150 free credits
- Daily free credits: 5 credits every 24 hours
- Pricing: 100 mana = €1.00
---
## 🔄 What Changed for Users
| Aspect | Before (Supabase) | After (Mana Core) | Impact |
|--------|-------------------|-------------------|---------|
| **Registration** | Immediate session | Register → Login | Minimal (auto-login in mobile) |
| **Login** | Supabase JWT | Mana Core JWT | None (transparent) |
| **Token Storage** | Supabase cookies | localStorage/SecureStore | None (same security) |
| **Sessions** | Supabase sessions | JWT + refresh tokens | Better (token rotation) |
| **Credits** | ❌ None | ✅ 150 initial + 5 daily | **NEW FEATURE!** |
---
## 📊 Port Configuration
| Service | Port | URL |
|---------|------|-----|
| **Mana Core Auth** | 3001 | http://localhost:3001 |
| **Chat Backend** | 3002 | http://localhost:3002 |
| **Web App** | 5173 | http://localhost:5173 |
| **Mobile App** | 8081 | exp://localhost:8081 |
---
## 🐛 Potential Issues & Solutions
### Issue: "Connection refused" to Mana Core Auth
**Solution:** Make sure Mana Core Auth is running on port 3001
```bash
cd mana-core-auth && pnpm start:dev
```
### Issue: "Invalid token" errors
**Solution:** Clear stored tokens and login again
```typescript
// Web: Clear localStorage
localStorage.clear();
// Mobile: Uninstall and reinstall app, or clear SecureStore
await SecureStore.deleteItemAsync('@auth/appToken');
await SecureStore.deleteItemAsync('@auth/refreshToken');
```
### Issue: CORS errors from web app
**Solution:** Add web app URL to Mana Core Auth CORS config
```env
# In mana-core-auth/.env
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
```
### Issue: Backend can't validate tokens
**Solution:** Check MANA_CORE_AUTH_URL in backend .env
```env
MANA_CORE_AUTH_URL=http://localhost:3001
```
---
## 📚 API Endpoint Reference
### Mana Core Auth (Port 3001)
- POST `/api/v1/auth/register` - Register new user
- POST `/api/v1/auth/login` - Login with email/password
- POST `/api/v1/auth/refresh` - Refresh access token
- POST `/api/v1/auth/logout` - Logout and revoke session
- POST `/api/v1/auth/validate` - Validate JWT token
- GET `/api/v1/credits/balance` - Get credit balance
### Chat Backend (Port 3002)
- GET `/api/chat/models` - List AI models (protected)
- POST `/api/chat/completions` - Create chat completion (protected)
- GET `/api/conversations` - List conversations (protected)
- POST `/api/conversations` - Create conversation (protected)
- GET `/api/conversations/:id` - Get conversation (protected)
- GET `/api/conversations/:id/messages` - Get messages (protected)
- POST `/api/conversations/:id/messages` - Add message (protected)
---
## 🎓 Next Steps (Optional Enhancements)
1. **Add Credit Usage Tracking**
- Deduct credits when using AI models
- Show remaining credits in UI
2. **Add OAuth Providers**
- Google Sign-In
- Apple Sign-In
3. **Add Email Verification**
- Send verification emails on signup
- Verify email before allowing login
4. **Add Password Reset**
- Implement forgot password flow
- Send reset emails
5. **Add 2FA**
- Enable two-factor authentication
- Support TOTP apps
6. **Add Session Management**
- Show active sessions
- Revoke specific sessions
---
## 📖 Documentation
- **Integration Guide:** `/chat/MANA_CORE_AUTH_INTEGRATION.md`
- **Mana Core Auth README:** `/mana-core-auth/README.md`
- **Quick Start:** `/mana-core-auth/QUICKSTART.md`
- **Master Plan:** `/.hive-mind/MASTER_PLAN_CENTRAL_AUTH_SYSTEM.md`
---
## ✨ Benefits of Migration
1. **✅ Centralized Authentication** - Single auth system for all Mana Core apps
2. **✅ Built-in Credits** - No need to build separate credit system
3. **✅ Better Security** - RS256 JWT, refresh token rotation, optimistic locking
4. **✅ Cost Savings** - Self-hosted, no per-user charges
5. **✅ Full Control** - Complete ownership of user data
6. **✅ Consistent API** - Same auth flow across all apps
---
**Status:** 🎉 **INTEGRATION COMPLETE - READY FOR TESTING!**
All code changes are done. Follow the "How to Run" section above to test the integration.

View file

@ -0,0 +1,544 @@
# Mana Core Auth Integration Guide - Chat Project
This guide explains how to integrate the Chat project with the new **Mana Core Auth** system, replacing Supabase Auth.
## Overview
The Chat project currently uses **Supabase Auth** across all apps. We're migrating to **Mana Core Auth**, our centralized authentication system with built-in credit management.
### Benefits
- ✅ **Unified Authentication** - Single auth system for all Mana Core apps
- ✅ **Built-in Credits** - Automatic credit balance management (150 signup bonus + 5 daily)
- ✅ **Better Security** - RS256 JWT, refresh token rotation, optimistic locking
- ✅ **Cost Savings** - Self-hosted, no per-user charges
- ✅ **Full Control** - Complete ownership of user data and auth flow
## Architecture
```
Chat Apps (Web, Mobile, Landing)
@manacore/shared-auth (Client Library)
Mana Core Auth Service (NestJS)
PostgreSQL (Users, Sessions, Credits)
```
## What Changed
### 1. Shared Auth Package Updated ✅
The `@manacore/shared-auth` package has been updated to work with Mana Core Auth endpoints:
**Updated endpoints:**
- `POST /api/v1/auth/register` - User registration
- `POST /api/v1/auth/login` - Email/password login
- `POST /api/v1/auth/refresh` - Token refresh
- `POST /api/v1/auth/logout` - Logout
- `GET /api/v1/credits/balance` - Get credit balance
**Response format changes:**
- Login returns: `{ accessToken, refreshToken, user, expiresIn, tokenType }`
- Credits balance returns: `{ balance, freeCreditsRemaining, totalEarned, totalSpent }`
## Step-by-Step Integration
### Step 1: Update Environment Variables
#### Backend `.env`
```env
# Remove Supabase variables
# SUPABASE_URL=...
# SUPABASE_SERVICE_KEY=...
# Add Mana Core Auth URL
MANA_CORE_AUTH_URL=http://localhost:3001
```
#### Web App `.env`
```env
# Remove
# PUBLIC_SUPABASE_URL=...
# PUBLIC_SUPABASE_ANON_KEY=...
# Add
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
#### Mobile App `.env`
```env
# Remove
# EXPO_PUBLIC_SUPABASE_URL=...
# EXPO_PUBLIC_SUPABASE_ANON_KEY=...
# Add
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
### Step 2: Update Backend (NestJS)
#### 2.1 Install Dependencies
```bash
cd chat/backend
pnpm add jsonwebtoken
pnpm add -D @types/jsonwebtoken
```
#### 2.2 Create JWT Auth Guard
Create `chat/backend/src/common/guards/jwt-auth.guard.ts`:
```typescript
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
// Get public key from Mana Core Auth
const authUrl = this.configService.get<string>('MANA_CORE_AUTH_URL');
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
if (!valid) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
};
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
```
#### 2.3 Update Controllers
Replace Supabase guards with JWT Auth guard:
```typescript
// Before
import { UseGuards } from '@nestjs/common';
import { SupabaseGuard } from './guards/supabase.guard';
@UseGuards(SupabaseGuard)
// After
import { UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
@UseGuards(JwtAuthGuard)
```
### Step 3: Update Web App (SvelteKit)
#### 3.1 Update Auth Store
Edit `chat/apps/web/src/lib/stores/auth.svelte.ts`:
```typescript
import { initializeWebAuth } from '@manacore/shared-auth';
const MANA_AUTH_URL = import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Initialize Mana Core Auth
const { authService, tokenManager } = initializeWebAuth({
baseUrl: MANA_AUTH_URL,
});
class AuthStore {
user = $state<UserData | null>(null);
isLoading = $state(true);
async initialize() {
this.isLoading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
this.user = userData;
}
} finally {
this.isLoading = false;
}
}
async signIn(email: string, password: string) {
const result = await authService.signIn(email, password);
if (result.success) {
const userData = await authService.getUserFromToken();
this.user = userData;
}
return result;
}
async signUp(email: string, password: string) {
const result = await authService.signUp(email, password);
// After signup, automatically sign in
if (result.success) {
return this.signIn(email, password);
}
return result;
}
async signOut() {
await authService.signOut();
this.user = null;
}
async resetPassword(email: string) {
return authService.forgotPassword(email);
}
}
export const authStore = new AuthStore();
```
#### 3.2 Update Server Hooks
Edit `chat/apps/web/src/hooks.server.ts`:
```typescript
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// Get token from cookies
const token = event.cookies.get('auth_token');
if (token) {
try {
// Validate token with Mana Core Auth
const authUrl = process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (response.ok) {
const { valid, payload } = await response.json();
if (valid) {
event.locals.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
};
}
}
} catch (error) {
console.error('Error validating token:', error);
}
}
return resolve(event);
};
```
### Step 4: Update Mobile App (Expo)
#### 4.1 Update AuthProvider
Edit `chat/apps/mobile/context/AuthProvider.tsx`:
```typescript
import React, { createContext, useContext, useEffect, useState } from 'react';
import * as SecureStore from 'expo-secure-store';
import {
createAuthService,
createTokenManager,
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
type UserData,
} from '@manacore/shared-auth';
import { createSecureStoreAdapter } from '@manacore/shared-auth/native'; // You may need to create this
const MANA_AUTH_URL = process.env.EXPO_PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Initialize auth service
const authService = createAuthService({ baseUrl: MANA_AUTH_URL });
const tokenManager = createTokenManager(authService);
type AuthContextType = {
user: UserData | null;
isLoading: boolean;
signIn: (email: string, password: string) => Promise<void>;
signUp: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
resetPassword: (email: string) => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<UserData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
initialize();
}, []);
async function initialize() {
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
setUser(userData);
}
} catch (error) {
console.error('Auth initialization error:', error);
} finally {
setIsLoading(false);
}
}
async function signIn(email: string, password: string) {
const result = await authService.signIn(email, password);
if (result.success) {
const userData = await authService.getUserFromToken();
setUser(userData);
} else {
throw new Error(result.error || 'Sign in failed');
}
}
async function signUp(email: string, password: string) {
const result = await authService.signUp(email, password);
if (result.success) {
// Auto sign in after signup
await signIn(email, password);
} else {
throw new Error(result.error || 'Sign up failed');
}
}
async function signOut() {
await authService.signOut();
setUser(null);
}
async function resetPassword(email: string) {
const result = await authService.forgotPassword(email);
if (!result.success) {
throw new Error(result.error || 'Password reset failed');
}
}
return (
<AuthContext.Provider
value={{ user, isLoading, signIn, signUp, signOut, resetPassword }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
```
### Step 5: Remove Supabase Dependencies
#### 5.1 Web App
```bash
cd chat/apps/web
pnpm remove @supabase/ssr @supabase/supabase-js
```
Delete or update these files:
- `src/lib/services/supabase.ts` (no longer needed)
#### 5.2 Mobile App
```bash
cd chat/apps/mobile
pnpm remove @supabase/supabase-js
```
Delete or update these files:
- `utils/supabase.ts` (no longer needed)
#### 5.3 Backend
```bash
cd chat/backend
pnpm remove @supabase/supabase-js
```
### Step 6: Test the Integration
#### 6.1 Start Mana Core Auth
```bash
# From monorepo root
cd mana-core-auth
pnpm start:dev
```
Service should be running at `http://localhost:3001`
#### 6.2 Start Chat Backend
```bash
cd chat/backend
pnpm start:dev
```
#### 6.3 Start Web App
```bash
cd chat/apps/web
pnpm dev
```
#### 6.4 Test Flow
1. **Register a new user**
- Go to `/register`
- Enter email and password
- Should redirect to login
2. **Login**
- Enter credentials
- Should receive tokens and redirect to app
3. **Check credits**
- User should have 150 initial credits
- Call `authService.getUserCredits()` to verify
4. **Protected routes**
- Try accessing `/chat` or other protected routes
- Should work with valid token
5. **Logout**
- Click logout
- Tokens should be cleared
- Should redirect to login
## API Compatibility
### Mana Core Auth vs Supabase
| Feature | Supabase Auth | Mana Core Auth | Status |
|---------|---------------|----------------|--------|
| Email/Password | ✅ | ✅ | Migrated |
| OAuth (Google) | ✅ | 🚧 | TODO |
| OAuth (Apple) | ✅ | 🚧 | TODO |
| Password Reset | ✅ | 🚧 | TODO |
| Email Verification | ✅ | 🚧 | TODO |
| Credits | ❌ | ✅ | New! |
| Session Management | ✅ | ✅ | Migrated |
| JWT Tokens | ✅ | ✅ | Migrated |
## Credits System
Mana Core Auth includes a built-in credit system:
```typescript
// Get credit balance
const credits = await authService.getUserCredits();
console.log(credits);
// {
// credits: 150, // balance + freeCreditsRemaining
// maxCreditLimit: 1000,
// userId: "user-id"
// }
```
### Default Credit Allocation
- **Signup Bonus:** 150 free credits
- **Daily Free:** 5 credits every 24 hours
- **Pricing:** 100 mana = €1.00
## Troubleshooting
### "Connection refused" to Mana Core Auth
**Solution:** Make sure Mana Core Auth is running:
```bash
cd mana-core-auth
pnpm start:dev
```
### "Invalid token" errors
**Solution:** Clear stored tokens and login again:
```typescript
await authService.clearAuthStorage();
```
### CORS errors
**Solution:** Add Chat app URLs to Mana Core Auth `.env`:
```env
CORS_ORIGINS=http://localhost:3000,http://localhost:8081
```
## Next Steps
1. ✅ Update `@manacore/shared-auth` package
2. ⏳ Integrate into Chat backend
3. ⏳ Update Chat web app
4. ⏳ Update Chat mobile app
5. ⏳ Test end-to-end
6. 🔜 Add OAuth providers
7. 🔜 Add email verification
8. 🔜 Add password reset
## Resources
- **Mana Core Auth README:** `/mana-core-auth/README.md`
- **Shared Auth Package:** `/packages/shared-auth/`
- **API Documentation:** `/mana-core-auth/README.md#api-endpoints`
- **Quick Start:** `/mana-core-auth/QUICKSTART.md`
---
**Status:** 🚧 Integration Guide Complete - Implementation Pending
**Date:** 2025-11-25

681
apps/chat/TESTING_GUIDE.md Normal file
View file

@ -0,0 +1,681 @@
# Testing Guide - Mana Core Auth Integration
This guide walks you through testing the Chat project with Mana Core Auth.
---
## Prerequisites
Before testing, make sure you have:
- ✅ Node.js 20+
- ✅ pnpm installed
- ✅ All dependencies installed (`pnpm install` from monorepo root)
- ✅ PostgreSQL running (or Docker for Mana Core Auth)
---
## Step 1: Generate JWT Keys for Mana Core Auth
Mana Core Auth requires RS256 JWT keys. Generate them first:
```bash
cd mana-core-auth
chmod +x scripts/generate-keys.sh
./scripts/generate-keys.sh
```
**You'll see output like:**
```
Generating RS256 key pair...
Keys generated successfully!
Private key: private.pem
Public key: public.pem
Add these to your .env file:
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKC...
-----END RSA PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MIIBIjANBg...
-----END PUBLIC KEY-----"
```
**Copy these keys - you'll need them in the next step!**
---
## Step 2: Configure Environment Variables
### 2.1 Mana Core Auth
```bash
cd mana-core-auth
cp .env.example .env
```
Edit `mana-core-auth/.env` and add:
```env
# Database
DATABASE_URL=postgresql://manacore:password@localhost:5432/manacore
# Paste the keys from Step 1
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
YOUR_PRIVATE_KEY_HERE
-----END RSA PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
YOUR_PUBLIC_KEY_HERE
-----END PUBLIC KEY-----"
# Other settings (use defaults for now)
REDIS_PASSWORD=
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
PORT=3001
```
### 2.2 Chat Backend
```bash
cd ../chat/backend
cp .env.example .env
```
Edit `chat/backend/.env`:
```env
# Azure OpenAI (your existing keys)
AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com
AZURE_OPENAI_API_KEY=your-api-key
AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Mana Core Auth (NEW)
MANA_CORE_AUTH_URL=http://localhost:3001
# Supabase (for database, not auth)
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-key
# Server
PORT=3002
```
### 2.3 Chat Web App
```bash
cd ../apps/web
cp .env.example .env
```
Edit `chat/apps/web/.env`:
```env
# Mana Core Auth (NEW)
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# Backend API (NEW PORT)
PUBLIC_BACKEND_URL=http://localhost:3002
# Supabase (for database)
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```
### 2.4 Chat Mobile App
```bash
cd ../mobile
cp .env.example .env
```
Edit `chat/apps/mobile/.env`:
```env
# Mana Core Auth (NEW)
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# Backend API (NEW PORT)
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
# Supabase (for database)
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```
---
## Step 3: Start Services (4 Terminals)
### Terminal 1: Mana Core Auth
```bash
cd mana-core-auth
# Start PostgreSQL (if using Docker)
docker-compose up postgres -d
# Run migrations
pnpm migration:run
# Start auth service
pnpm start:dev
```
**Expected output:**
```
🚀 Mana Core Auth running on: http://localhost:3001
📚 Environment: development
```
**Leave this running!**
### Terminal 2: Chat Backend
```bash
cd chat/backend
pnpm start:dev
```
**Expected output:**
```
[Nest] LOG [NestApplication] Nest application successfully started
[Nest] LOG Listening on port 3002
```
**Leave this running!**
### Terminal 3: Chat Web App
```bash
cd chat/apps/web
pnpm dev
```
**Expected output:**
```
VITE v5.x.x ready in xxx ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
```
**Leave this running!**
### Terminal 4: Chat Mobile App (Optional)
```bash
cd chat/apps/mobile
pnpm dev
```
**Expected output:**
```
Metro waiting on exp://localhost:8081
Scan the QR code above with Expo Go (Android) or Camera (iOS)
```
---
## Step 4: Test Web App Authentication
### 4.1 Open Web App
Open browser: http://localhost:5173
### 4.2 Register New User
1. Click **"Register"** or go to `/register`
2. Enter test credentials:
- Email: `test@example.com`
- Password: `Test1234!`
3. Click **"Register"**
**Expected:**
- Registration succeeds
- Automatically redirects to login
- Login happens automatically
- You're redirected to the main app
### 4.3 Check Authentication
Open browser console (F12) and run:
```javascript
// Check if user is authenticated
console.log('Authenticated:', window.localStorage.getItem('@auth/appToken'));
```
**Expected:** You should see a JWT token
### 4.4 Check Credits
In browser console:
```javascript
// Get credit balance
const authStore = window.authStore; // If exported globally
// Or navigate to a page that displays credits
```
**Expected:**
- 150 initial credits
- API call to `/api/v1/credits/balance` succeeds
### 4.5 Test Logout
1. Click **"Logout"** button
2. Check you're redirected to login page
3. Try accessing protected route → Should redirect to login
---
## Step 5: Test Backend API
### 5.1 Get Access Token
First, login via web app, then get the token from localStorage:
**In browser console:**
```javascript
const token = localStorage.getItem('@auth/appToken');
console.log(token);
// Copy this token!
```
### 5.2 Test Protected Endpoints
Use `curl` or Postman/Insomnia:
#### Test 1: Get AI Models (Protected)
```bash
# Without token - Should fail with 401
curl http://localhost:3002/api/chat/models
# With token - Should succeed
curl http://localhost:3002/api/chat/models \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
**Expected Response:**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "GPT-O3-Mini",
"description": "Azure OpenAI O3-Mini: Effizientes Modell für schnelle Antworten.",
...
},
...
]
```
#### Test 2: List Conversations (Protected)
```bash
curl http://localhost:3002/api/conversations \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
**Expected:** Array of conversations (may be empty for new user)
#### Test 3: Create Conversation (Protected)
```bash
curl -X POST http://localhost:3002/api/conversations \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"modelId": "550e8400-e29b-41d4-a716-446655440000",
"title": "Test Conversation"
}'
```
**Expected:** New conversation object
#### Test 4: Chat Completion (Protected)
```bash
curl -X POST http://localhost:3002/api/chat/completions \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"modelId": "550e8400-e29b-41d4-a716-446655440000",
"messages": [
{"role": "user", "content": "Say hello!"}
]
}'
```
**Expected:** AI response with content and usage stats
---
## Step 6: Test Mobile App (Optional)
### 6.1 Install Expo Go
- **iOS:** Install from App Store
- **Android:** Install from Google Play Store
### 6.2 Scan QR Code
1. Look at Terminal 4 (mobile app terminal)
2. Scan the QR code with:
- iOS: Camera app
- Android: Expo Go app
### 6.3 Register/Login
1. App opens to login screen
2. Tap **"Register"**
3. Enter credentials:
- Email: `mobile@example.com`
- Password: `Mobile1234!`
4. Tap **"Register"**
**Expected:**
- Registration succeeds
- Auto-login
- Redirected to chat interface
### 6.4 Test Chat
1. Try sending a message
2. Should get AI response
3. Check conversation is saved
---
## Step 7: Test Token Validation
### 7.1 Test Invalid Token
```bash
curl http://localhost:3002/api/chat/models \
-H "Authorization: Bearer invalid-token-here"
```
**Expected:**
```json
{
"statusCode": 401,
"message": "Invalid token"
}
```
### 7.2 Test Expired Token
After 15 minutes, the access token expires. Try using it:
```bash
curl http://localhost:3002/api/chat/models \
-H "Authorization: Bearer EXPIRED_TOKEN"
```
**Expected:** 401 Unauthorized
### 7.3 Test Token Refresh
The `@manacore/shared-auth` package automatically refreshes tokens. To test:
1. Wait 15+ minutes (or change `JWT_ACCESS_TOKEN_EXPIRY=1m` for testing)
2. Make an API call from web/mobile app
3. Check Network tab - should see automatic refresh
4. Request succeeds with new token
---
## Step 8: Test Credit System
### 8.1 Check Initial Balance
```bash
curl http://localhost:3001/api/v1/credits/balance \
-H "Authorization: Bearer YOUR_TOKEN"
```
**Expected:**
```json
{
"balance": 0,
"freeCreditsRemaining": 150,
"totalEarned": 0,
"totalSpent": 0,
"dailyFreeCredits": 5
}
```
### 8.2 Use Credits
```bash
curl -X POST http://localhost:3001/api/v1/credits/use \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"amount": 10,
"appId": "chat",
"description": "Test chat completion",
"idempotencyKey": "test-123"
}'
```
**Expected:**
```json
{
"success": true,
"transaction": { ... },
"newBalance": {
"balance": 0,
"freeCreditsRemaining": 140,
"totalSpent": 10
}
}
```
### 8.3 Check Transaction History
```bash
curl http://localhost:3001/api/v1/credits/transactions \
-H "Authorization: Bearer YOUR_TOKEN"
```
**Expected:** Array with signup bonus and usage transactions
---
## Common Issues & Solutions
### Issue 1: "Connection refused" to port 3001
**Problem:** Mana Core Auth not running
**Solution:**
```bash
cd mana-core-auth
pnpm start:dev
```
### Issue 2: "Invalid token" errors
**Problem:** JWT keys mismatch or token expired
**Solution:**
1. Clear tokens: `localStorage.clear()` in browser
2. Login again
3. Verify JWT keys are identical in Mana Core Auth .env
### Issue 3: CORS errors in browser
**Problem:** Web app URL not in CORS whitelist
**Solution:**
Edit `mana-core-auth/.env`:
```env
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
```
Restart Mana Core Auth
### Issue 4: "Database connection failed"
**Problem:** PostgreSQL not running
**Solution:**
```bash
# If using Docker
cd mana-core-auth
docker-compose up postgres -d
# Check it's running
docker-compose ps
```
### Issue 5: "Port 3001 already in use"
**Problem:** Another service using port 3001
**Solution:**
```bash
# Find what's using the port
lsof -ti:3001
# Kill it
kill -9 $(lsof -ti:3001)
```
### Issue 6: Mobile app can't connect
**Problem:** Using localhost on mobile device
**Solution:**
Edit `chat/apps/mobile/.env`:
```env
# Replace localhost with your computer's IP
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://192.168.1.XXX:3001
EXPO_PUBLIC_BACKEND_URL=http://192.168.1.XXX:3002
```
Get your IP:
```bash
# macOS
ipconfig getifaddr en0
# Linux
hostname -I
```
---
## Quick Test Script
Save this as `test-auth.sh`:
```bash
#!/bin/bash
echo "🧪 Testing Mana Core Auth Integration"
echo ""
# Test 1: Register user
echo "1⃣ Testing registration..."
REGISTER_RESPONSE=$(curl -s -X POST http://localhost:3001/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"Test1234!"}')
echo "Response: $REGISTER_RESPONSE"
echo ""
# Test 2: Login
echo "2⃣ Testing login..."
LOGIN_RESPONSE=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"Test1234!"}')
# Extract token
TOKEN=$(echo $LOGIN_RESPONSE | grep -o '"accessToken":"[^"]*' | cut -d'"' -f4)
if [ -z "$TOKEN" ]; then
echo "❌ Login failed!"
echo "Response: $LOGIN_RESPONSE"
exit 1
fi
echo "✅ Login successful! Token: ${TOKEN:0:50}..."
echo ""
# Test 3: Get credits
echo "3⃣ Testing credit balance..."
CREDITS_RESPONSE=$(curl -s http://localhost:3001/api/v1/credits/balance \
-H "Authorization: Bearer $TOKEN")
echo "Response: $CREDITS_RESPONSE"
echo ""
# Test 4: Backend protected endpoint
echo "4⃣ Testing backend protected endpoint..."
MODELS_RESPONSE=$(curl -s http://localhost:3002/api/chat/models \
-H "Authorization: Bearer $TOKEN")
echo "Response: $MODELS_RESPONSE"
echo ""
echo "✅ All tests complete!"
```
Make it executable and run:
```bash
chmod +x test-auth.sh
./test-auth.sh
```
---
## Testing Checklist
Use this checklist to verify everything works:
### Mana Core Auth ✅
- [ ] Service starts on port 3001
- [ ] Can register new user
- [ ] Can login with credentials
- [ ] Can refresh access token
- [ ] Can logout
- [ ] Can check credit balance
- [ ] Can use credits
### Chat Backend ✅
- [ ] Service starts on port 3002
- [ ] Protected endpoints return 401 without token
- [ ] Protected endpoints work with valid token
- [ ] Can list AI models
- [ ] Can create conversation
- [ ] Can list conversations
- [ ] Can send messages
### Web App ✅
- [ ] App loads on port 5173
- [ ] Can register new user
- [ ] Can login
- [ ] Can logout
- [ ] Can access protected routes
- [ ] Can send chat messages
- [ ] Can see conversations
### Mobile App ✅
- [ ] App loads in Expo Go
- [ ] Can register new user
- [ ] Can login
- [ ] Can logout
- [ ] Can send chat messages
- [ ] Can see conversations
- [ ] Tokens persist after app restart
---
**Status:** Ready for Testing! 🚀
Follow these steps and check off items as you test. If you encounter issues, check the "Common Issues" section above.

View file

@ -0,0 +1,37 @@
# Dependencies
node_modules
.pnpm-store
# Build output
dist
*.tsbuildinfo
# Development files
.env
.env.local
.env.*.local
# IDE
.idea
.vscode
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Test files
coverage
.nyc_output
# Misc
*.md
!README.md
.git
.gitignore

View file

@ -0,0 +1,20 @@
# Docker Environment Configuration
# Copy this file to .env and fill in the values
# Database Configuration
DB_USER=chat
DB_PASSWORD=chatpassword
DB_NAME=chat
DB_PORT=5432
# Azure OpenAI Configuration (required)
AZURE_OPENAI_ENDPOINT=https://your-azure-openai-endpoint.openai.azure.com
AZURE_OPENAI_API_KEY=your-api-key-here
AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Mana Core Auth URL
# Use host.docker.internal to connect to services running on host machine
MANA_CORE_AUTH_URL=http://host.docker.internal:3001
# Backend Port (exposed on host)
BACKEND_PORT=3002

View file

@ -0,0 +1,13 @@
# Azure OpenAI Configuration
AZURE_OPENAI_ENDPOINT=https://your-azure-openai-endpoint.openai.azure.com
AZURE_OPENAI_API_KEY=your-api-key-here
AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Mana Core Auth Configuration
MANA_CORE_AUTH_URL=http://localhost:3001
# PostgreSQL Database Configuration
DATABASE_URL=postgresql://chat:password@localhost:5432/chat
# Server Configuration
PORT=3002

View file

@ -0,0 +1,63 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/shared-errors ./packages/shared-errors
# Copy chat backend
COPY apps/chat/apps/backend ./apps/chat/apps/backend
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/shared-errors
RUN pnpm build
# Build the backend
WORKDIR /app/apps/chat/apps/backend
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client for health checks
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
WORKDIR /app
# Copy everything from builder (including node_modules)
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/chat/apps/backend ./apps/chat/apps/backend
# Copy entrypoint script
COPY apps/chat/apps/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/apps/chat/apps/backend
# Expose port
EXPOSE 3002
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,67 @@
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: chat-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-chat}
POSTGRES_PASSWORD: ${DB_PASSWORD:-chatpassword}
POSTGRES_DB: ${DB_NAME:-chat}
ports:
- "${DB_PORT:-5433}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-chat} -d ${DB_NAME:-chat}"]
interval: 10s
timeout: 5s
retries: 5
# Chat Backend API
backend:
build:
context: ../..
dockerfile: chat/backend/Dockerfile
container_name: chat-backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
# Database
DATABASE_URL: postgresql://${DB_USER:-chat}:${DB_PASSWORD:-chatpassword}@postgres:5432/${DB_NAME:-chat}
DB_HOST: postgres
DB_PORT: 5432
DB_USER: ${DB_USER:-chat}
DB_PASSWORD: ${DB_PASSWORD:-chatpassword}
DB_NAME: ${DB_NAME:-chat}
# Azure OpenAI
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT}
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY}
AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-2024-12-01-preview}
# Mana Core Auth
MANA_CORE_AUTH_URL: ${MANA_CORE_AUTH_URL:-http://host.docker.internal:3001}
# Server
PORT: 3002
NODE_ENV: production
ports:
- "${BACKEND_PORT:-3002}:3002"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"]
interval: 30s
timeout: 10s
start_period: 30s
retries: 3
volumes:
postgres_data:
driver: local
networks:
default:
name: chat-network

View file

@ -0,0 +1,34 @@
#!/bin/sh
set -e
echo "=== Chat Backend Entrypoint ==="
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL..."
until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-chat} 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
echo "PostgreSQL is up!"
cd /app/chat/backend
# Run schema push (for development) or migrations (for production)
if [ "$NODE_ENV" = "production" ] && [ -d "src/db/migrations/meta" ]; then
echo "Running database migrations..."
npx tsx src/db/migrate.ts
echo "Migrations completed!"
else
echo "Pushing database schema (development mode)..."
npx drizzle-kit push --force
echo "Schema push completed!"
fi
# Run seed (only seeds if data doesn't exist)
echo "Running database seed..."
npx tsx src/db/seed.ts
echo "Seed completed!"
# Execute the main command
echo "Starting application..."
exec "$@"

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://chat:password@localhost:5432/chat',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": [],
"watchAssets": false
}
}

View file

@ -0,0 +1,60 @@
{
"name": "@chat/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"docker:build": "docker compose build",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f",
"docker:restart": "docker compose restart",
"docker:clean": "docker compose down -v --rmi local"
},
"dependencies": {
"@manacore/shared-errors": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"openai": "^4.77.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { ChatModule } from './chat/chat.module';
import { ConversationModule } from './conversation/conversation.module';
import { TemplateModule } from './template/template.module';
import { SpaceModule } from './space/space.module';
import { DocumentModule } from './document/document.module';
import { ModelModule } from './model/model.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
ChatModule,
ConversationModule,
TemplateModule,
SpaceModule,
DocumentModule,
ModelModule,
HealthModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,37 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { ChatService } from './chat.service';
import {
ChatCompletionDto,
ChatCompletionResponseDto,
} from './dto/chat-completion.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
@Controller('chat')
@UseGuards(JwtAuthGuard)
export class ChatController {
constructor(private readonly chatService: ChatService) {}
@Get('models')
async getModels() {
return this.chatService.getAvailableModels();
}
@Post('completions')
async createCompletion(
@Body() dto: ChatCompletionDto,
@CurrentUser() user: CurrentUserData,
): Promise<ChatCompletionResponseDto> {
const result = await this.chatService.createCompletion(dto, user.userId);
if (!isOk(result)) {
throw result.error; // Caught by AppExceptionFilter
}
return result.value;
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ChatController } from './chat.controller';
import { ChatService } from './chat.service';
@Module({
controllers: [ChatController],
providers: [ChatService],
exports: [ChatService],
})
export class ChatModule {}

View file

@ -0,0 +1,168 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
ValidationError,
ServiceError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { models, type Model } from '../db/schema/models.schema';
import { ChatCompletionDto, ChatCompletionResponseDto } from './dto/chat-completion.dto';
@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
private readonly apiKey: string;
private readonly endpoint: string;
private readonly apiVersion: string;
constructor(
private configService: ConfigService,
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {
this.apiKey = this.configService.get<string>('AZURE_OPENAI_API_KEY') || '';
this.endpoint =
this.configService.get<string>('AZURE_OPENAI_ENDPOINT') ||
'https://memoroseopenai.openai.azure.com';
this.apiVersion =
this.configService.get<string>('AZURE_OPENAI_API_VERSION') ||
'2024-12-01-preview';
if (!this.apiKey) {
this.logger.warn('AZURE_OPENAI_API_KEY is not set!');
}
}
async getAvailableModels(): Promise<Model[]> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.isActive, true));
return result;
} catch (error) {
this.logger.error('Error fetching models from database', error);
return [];
}
}
async getModelById(modelId: string): Promise<Model | undefined> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.id, modelId))
.limit(1);
return result[0];
} catch (error) {
this.logger.error('Error fetching model from database', error);
return undefined;
}
}
async createCompletion(
dto: ChatCompletionDto,
userId?: string,
): AsyncResult<ChatCompletionResponseDto> {
const model = await this.getModelById(dto.modelId);
if (!model) {
return err(
ValidationError.invalidInput('modelId', `Model ${dto.modelId} not found`),
);
}
// Log user context for tracking (optional)
if (userId) {
this.logger.log(
`User ${userId} creating chat completion with model ${dto.modelId}`,
);
}
const params = model.parameters as {
deployment?: string;
temperature?: number;
max_tokens?: number;
} | null;
const deployment = params?.deployment || 'gpt-4o-mini-se';
const temperature = dto.temperature ?? params?.temperature ?? 0.7;
const maxTokens = dto.maxTokens ?? params?.max_tokens ?? 1000;
// Prepare request body
const requestBody: Record<string, unknown> = {
messages: dto.messages.map((msg) => ({
role: msg.role,
content: msg.content,
})),
};
// Model-specific parameters
const isGPTOModel =
deployment.includes('gpt-o') || deployment.includes('gpt-4o');
if (!isGPTOModel) {
requestBody.max_tokens = maxTokens;
requestBody.temperature = temperature;
}
const url = `${this.endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${this.apiVersion}`;
this.logger.log(`Sending request to: ${url}`);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': this.apiKey,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
this.logger.error(`API error: ${response.status} - ${errorText}`);
return err(
ServiceError.externalError(
'Azure OpenAI',
`API error: ${response.status}`,
),
);
}
const data = await response.json();
const messageContent = data.choices?.[0]?.message?.content;
if (!messageContent) {
this.logger.warn('No message content in response');
return err(
ServiceError.generationFailed('Azure OpenAI', 'No response generated'),
);
}
return ok({
content: messageContent,
usage: {
prompt_tokens: data.usage?.prompt_tokens || 0,
completion_tokens: data.usage?.completion_tokens || 0,
total_tokens: data.usage?.total_tokens || 0,
},
});
} catch (error) {
this.logger.error('Error calling Azure OpenAI API', error);
return err(
ServiceError.generationFailed(
'Azure OpenAI',
error instanceof Error ? error.message : 'Unknown error',
error instanceof Error ? error : undefined,
),
);
}
}
}

View file

@ -0,0 +1,40 @@
import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class ChatMessageDto {
@IsString()
@IsNotEmpty()
role: 'system' | 'user' | 'assistant';
@IsString()
@IsNotEmpty()
content: string;
}
export class ChatCompletionDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages: ChatMessageDto[];
@IsString()
@IsNotEmpty()
modelId: string;
@IsNumber()
@IsOptional()
temperature?: number;
@IsNumber()
@IsOptional()
maxTokens?: number;
}
export class ChatCompletionResponseDto {
content: string;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}

View file

@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
userId: string;
email: string;
role: string;
sessionId?: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View file

@ -0,0 +1,66 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') ||
'http://localhost:3001';
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -0,0 +1,211 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { ConversationService } from './conversation.service';
import { type Conversation } from '../db/schema/conversations.schema';
import { type Message } from '../db/schema/messages.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
@Controller('conversations')
@UseGuards(JwtAuthGuard)
export class ConversationController {
constructor(private readonly conversationService: ConversationService) {}
@Get()
async getConversations(
@CurrentUser() user: CurrentUserData,
@Query('spaceId') spaceId?: string,
): Promise<Conversation[]> {
const result = await this.conversationService.getConversations(
user.userId,
spaceId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('archived')
async getArchivedConversations(
@CurrentUser() user: CurrentUserData,
): Promise<Conversation[]> {
const result = await this.conversationService.getArchivedConversations(
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id')
async getConversation(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Conversation> {
const result = await this.conversationService.getConversation(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id/messages')
async getMessages(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Message[]> {
const result = await this.conversationService.getMessages(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Post()
async createConversation(
@Body()
body: {
modelId: string;
title?: string;
templateId?: string;
conversationMode?: 'free' | 'guided' | 'template';
documentMode?: boolean;
spaceId?: string;
},
@CurrentUser() user: CurrentUserData,
): Promise<Conversation> {
const result = await this.conversationService.createConversation(
user.userId,
body.modelId,
{
title: body.title,
templateId: body.templateId,
conversationMode: body.conversationMode,
documentMode: body.documentMode,
spaceId: body.spaceId,
},
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Post(':id/messages')
async addMessage(
@Param('id') id: string,
@Body() body: { sender: 'user' | 'assistant' | 'system'; messageText: string },
@CurrentUser() user: CurrentUserData,
): Promise<Message> {
const result = await this.conversationService.addMessage(
id,
user.userId,
body.sender,
body.messageText,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id/title')
async updateTitle(
@Param('id') id: string,
@Body() body: { title: string },
@CurrentUser() user: CurrentUserData,
): Promise<Conversation> {
const result = await this.conversationService.updateTitle(
id,
user.userId,
body.title,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id/archive')
async archiveConversation(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Conversation> {
const result = await this.conversationService.archiveConversation(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id/unarchive')
async unarchiveConversation(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Conversation> {
const result = await this.conversationService.unarchiveConversation(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id')
async deleteConversation(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.conversationService.deleteConversation(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ConversationController } from './conversation.controller';
import { ConversationService } from './conversation.service';
@Module({
controllers: [ConversationController],
providers: [ConversationService],
exports: [ConversationService],
})
export class ConversationModule {}

View file

@ -0,0 +1,319 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
conversations,
type Conversation,
type NewConversation,
} from '../db/schema/conversations.schema';
import { messages, type Message, type NewMessage } from '../db/schema/messages.schema';
@Injectable()
export class ConversationService {
private readonly logger = new Logger(ConversationService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
async getConversations(
userId: string,
spaceId?: string,
): AsyncResult<Conversation[]> {
try {
const conditions = [
eq(conversations.userId, userId),
eq(conversations.isArchived, false),
];
if (spaceId) {
conditions.push(eq(conversations.spaceId, spaceId));
}
const result = await this.db
.select()
.from(conversations)
.where(and(...conditions))
.orderBy(desc(conversations.updatedAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching conversations', error);
return err(DatabaseError.queryFailed('Failed to fetch conversations'));
}
}
async getArchivedConversations(userId: string): AsyncResult<Conversation[]> {
try {
const result = await this.db
.select()
.from(conversations)
.where(
and(
eq(conversations.userId, userId),
eq(conversations.isArchived, true),
),
)
.orderBy(desc(conversations.updatedAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching archived conversations', error);
return err(DatabaseError.queryFailed('Failed to fetch archived conversations'));
}
}
async getConversation(id: string, userId: string): AsyncResult<Conversation> {
try {
const result = await this.db
.select()
.from(conversations)
.where(
and(eq(conversations.id, id), eq(conversations.userId, userId)),
)
.limit(1);
if (result.length === 0) {
return err(new NotFoundError('Conversation', id));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error fetching conversation', error);
return err(DatabaseError.queryFailed('Failed to fetch conversation'));
}
}
async getMessages(
conversationId: string,
userId: string,
): AsyncResult<Message[]> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const result = await this.db
.select()
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(asc(messages.createdAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching messages', error);
return err(DatabaseError.queryFailed('Failed to fetch messages'));
}
}
async createConversation(
userId: string,
modelId: string,
options?: {
title?: string;
templateId?: string;
conversationMode?: 'free' | 'guided' | 'template';
documentMode?: boolean;
spaceId?: string;
},
): AsyncResult<Conversation> {
try {
const newConversation: NewConversation = {
userId,
modelId,
title: options?.title || 'Neue Unterhaltung',
templateId: options?.templateId,
conversationMode: options?.conversationMode || 'free',
documentMode: options?.documentMode || false,
spaceId: options?.spaceId,
isArchived: false,
};
const result = await this.db
.insert(conversations)
.values(newConversation)
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating conversation', error);
return err(DatabaseError.queryFailed('Failed to create conversation'));
}
}
async addMessage(
conversationId: string,
userId: string,
sender: 'user' | 'assistant' | 'system',
messageText: string,
): AsyncResult<Message> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const newMessage: NewMessage = {
conversationId,
sender,
messageText,
};
const result = await this.db
.insert(messages)
.values(newMessage)
.returning();
// Update conversation updated_at
await this.db
.update(conversations)
.set({ updatedAt: new Date() })
.where(eq(conversations.id, conversationId));
return ok(result[0]);
} catch (error) {
this.logger.error('Error adding message', error);
return err(DatabaseError.queryFailed('Failed to add message'));
}
}
async updateTitle(
conversationId: string,
userId: string,
title: string,
): AsyncResult<Conversation> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const result = await this.db
.update(conversations)
.set({ title, updatedAt: new Date() })
.where(eq(conversations.id, conversationId))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error updating title', error);
return err(DatabaseError.queryFailed('Failed to update title'));
}
}
async archiveConversation(
conversationId: string,
userId: string,
): AsyncResult<Conversation> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const result = await this.db
.update(conversations)
.set({ isArchived: true, updatedAt: new Date() })
.where(eq(conversations.id, conversationId))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error archiving conversation', error);
return err(DatabaseError.queryFailed('Failed to archive conversation'));
}
}
async unarchiveConversation(
conversationId: string,
userId: string,
): AsyncResult<Conversation> {
try {
// First verify the conversation belongs to the user
const convResult = await this.db
.select()
.from(conversations)
.where(
and(
eq(conversations.id, conversationId),
eq(conversations.userId, userId),
),
)
.limit(1);
if (convResult.length === 0) {
return err(new NotFoundError('Conversation', conversationId));
}
const result = await this.db
.update(conversations)
.set({ isArchived: false, updatedAt: new Date() })
.where(eq(conversations.id, conversationId))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error unarchiving conversation', error);
return err(DatabaseError.queryFailed('Failed to unarchive conversation'));
}
}
async deleteConversation(
conversationId: string,
userId: string,
): AsyncResult<void> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
// Messages will be cascade deleted due to foreign key constraint
await this.db
.delete(conversations)
.where(eq(conversations.id, conversationId));
return ok(undefined);
} catch (error) {
this.logger.error('Error deleting conversation', error);
return err(DatabaseError.queryFailed('Failed to delete conversation'));
}
}
async getMessageCount(
conversationId: string,
userId: string,
): AsyncResult<number> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(messages)
.where(eq(messages.conversationId, conversationId));
return ok(Number(result[0]?.count || 0));
} catch (error) {
this.logger.error('Error getting message count', error);
return err(DatabaseError.queryFailed('Failed to get message count'));
}
}
}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,29 @@
import { config } from 'dotenv';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { getDb, closeConnection } from './connection';
// Load environment variables
config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('Running migrations...');
try {
const db = getDb(databaseUrl);
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations completed successfully');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await closeConnection();
}
}
runMigrations();

View file

@ -0,0 +1,43 @@
import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { messages } from './messages.schema';
import { documents } from './documents.schema';
import { spaces } from './spaces.schema';
import { models } from './models.schema';
import { templates } from './templates.schema';
export const conversationModeEnum = pgEnum('conversation_mode', ['free', 'guided', 'template']);
export const conversations = pgTable('conversations', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
modelId: uuid('model_id').references(() => models.id),
templateId: uuid('template_id').references(() => templates.id),
spaceId: uuid('space_id').references(() => spaces.id, { onDelete: 'set null' }),
title: text('title'),
conversationMode: conversationModeEnum('conversation_mode').default('free').notNull(),
documentMode: boolean('document_mode').default(false).notNull(),
isArchived: boolean('is_archived').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const conversationsRelations = relations(conversations, ({ one, many }) => ({
model: one(models, {
fields: [conversations.modelId],
references: [models.id],
}),
template: one(templates, {
fields: [conversations.templateId],
references: [templates.id],
}),
space: one(spaces, {
fields: [conversations.spaceId],
references: [spaces.id],
}),
messages: many(messages),
documents: many(documents),
}));
export type Conversation = typeof conversations.$inferSelect;
export type NewConversation = typeof conversations.$inferInsert;

View file

@ -0,0 +1,24 @@
import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { conversations } from './conversations.schema';
export const documents = pgTable('documents', {
id: uuid('id').primaryKey().defaultRandom(),
conversationId: uuid('conversation_id')
.references(() => conversations.id, { onDelete: 'cascade' })
.notNull(),
version: integer('version').default(1).notNull(),
content: text('content').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const documentsRelations = relations(documents, ({ one }) => ({
conversation: one(conversations, {
fields: [documents.conversationId],
references: [conversations.id],
}),
}));
export type Document = typeof documents.$inferSelect;
export type NewDocument = typeof documents.$inferInsert;

View file

@ -0,0 +1,7 @@
export * from './conversations.schema';
export * from './messages.schema';
export * from './models.schema';
export * from './templates.schema';
export * from './spaces.schema';
export * from './documents.schema';
export * from './usage-logs.schema';

View file

@ -0,0 +1,26 @@
import { pgTable, uuid, text, timestamp, pgEnum } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { conversations } from './conversations.schema';
export const senderEnum = pgEnum('sender', ['user', 'assistant', 'system']);
export const messages = pgTable('messages', {
id: uuid('id').primaryKey().defaultRandom(),
conversationId: uuid('conversation_id')
.references(() => conversations.id, { onDelete: 'cascade' })
.notNull(),
sender: senderEnum('sender').notNull(),
messageText: text('message_text').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const messagesRelations = relations(messages, ({ one }) => ({
conversation: one(conversations, {
fields: [messages.conversationId],
references: [conversations.id],
}),
}));
export type Message = typeof messages.$inferSelect;
export type NewMessage = typeof messages.$inferInsert;

View file

@ -0,0 +1,20 @@
import { pgTable, uuid, text, timestamp, jsonb, boolean } from 'drizzle-orm/pg-core';
export const models = pgTable('models', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
provider: text('provider').notNull(), // 'azure', 'openai', 'anthropic', etc.
parameters: jsonb('parameters').$type<{
deployment?: string;
temperature?: number;
max_tokens?: number;
top_p?: number;
}>(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Model = typeof models.$inferSelect;
export type NewModel = typeof models.$inferInsert;

View file

@ -0,0 +1,46 @@
import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const memberRoleEnum = pgEnum('member_role', ['owner', 'admin', 'member', 'viewer']);
export const invitationStatusEnum = pgEnum('invitation_status', ['pending', 'accepted', 'declined']);
export const spaces = pgTable('spaces', {
id: uuid('id').primaryKey().defaultRandom(),
ownerId: uuid('owner_id').notNull(),
name: text('name').notNull(),
description: text('description'),
isArchived: boolean('is_archived').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const spaceMembers = pgTable('space_members', {
id: uuid('id').primaryKey().defaultRandom(),
spaceId: uuid('space_id')
.references(() => spaces.id, { onDelete: 'cascade' })
.notNull(),
userId: uuid('user_id').notNull(),
role: memberRoleEnum('role').default('member').notNull(),
invitationStatus: invitationStatusEnum('invitation_status').default('pending').notNull(),
invitedBy: uuid('invited_by'),
invitedAt: timestamp('invited_at', { withTimezone: true }).defaultNow().notNull(),
joinedAt: timestamp('joined_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const spacesRelations = relations(spaces, ({ many }) => ({
members: many(spaceMembers),
}));
export const spaceMembersRelations = relations(spaceMembers, ({ one }) => ({
space: one(spaces, {
fields: [spaceMembers.spaceId],
references: [spaces.id],
}),
}));
export type Space = typeof spaces.$inferSelect;
export type NewSpace = typeof spaces.$inferInsert;
export type SpaceMember = typeof spaceMembers.$inferSelect;
export type NewSpaceMember = typeof spaceMembers.$inferInsert;

View file

@ -0,0 +1,28 @@
import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { models } from './models.schema';
export const templates = pgTable('templates', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
description: text('description'),
systemPrompt: text('system_prompt').notNull(),
initialQuestion: text('initial_question'),
modelId: uuid('model_id').references(() => models.id),
color: text('color').default('#3b82f6').notNull(),
isDefault: boolean('is_default').default(false).notNull(),
documentMode: boolean('document_mode').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const templatesRelations = relations(templates, ({ one }) => ({
model: one(models, {
fields: [templates.modelId],
references: [models.id],
}),
}));
export type Template = typeof templates.$inferSelect;
export type NewTemplate = typeof templates.$inferInsert;

View file

@ -0,0 +1,40 @@
import { pgTable, uuid, timestamp, integer, numeric } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { conversations } from './conversations.schema';
import { messages } from './messages.schema';
import { models } from './models.schema';
export const usageLogs = pgTable('usage_logs', {
id: uuid('id').primaryKey().defaultRandom(),
conversationId: uuid('conversation_id')
.references(() => conversations.id, { onDelete: 'cascade' })
.notNull(),
messageId: uuid('message_id')
.references(() => messages.id, { onDelete: 'cascade' })
.notNull(),
userId: uuid('user_id').notNull(),
modelId: uuid('model_id').references(() => models.id),
promptTokens: integer('prompt_tokens').default(0).notNull(),
completionTokens: integer('completion_tokens').default(0).notNull(),
totalTokens: integer('total_tokens').default(0).notNull(),
estimatedCost: numeric('estimated_cost', { precision: 10, scale: 6 }).default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const usageLogsRelations = relations(usageLogs, ({ one }) => ({
conversation: one(conversations, {
fields: [usageLogs.conversationId],
references: [conversations.id],
}),
message: one(messages, {
fields: [usageLogs.messageId],
references: [messages.id],
}),
model: one(models, {
fields: [usageLogs.modelId],
references: [models.id],
}),
}));
export type UsageLog = typeof usageLogs.$inferSelect;
export type NewUsageLog = typeof usageLogs.$inferInsert;

View file

@ -0,0 +1,100 @@
/**
* Database Seed Script
* Seeds initial data for the chat application
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { models } from './schema';
import * as dotenv from 'dotenv';
dotenv.config();
const connectionString = process.env.DATABASE_URL || 'postgresql://chat:password@localhost:5432/chat';
async function seed() {
console.log('Starting database seed...');
const client = postgres(connectionString);
const db = drizzle(client);
try {
// Check if models already exist
const existingModels = await db.select().from(models);
if (existingModels.length > 0) {
console.log(`Found ${existingModels.length} existing models. Skipping seed.`);
await client.end();
return;
}
// Seed AI models
console.log('Seeding AI models...');
const modelData = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'GPT-O3-Mini',
description: 'Fast, efficient responses for everyday tasks',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 800,
deployment: 'gpt-o3-mini-se',
},
isActive: true,
},
{
id: '550e8400-e29b-41d4-a716-446655440004',
name: 'GPT-4o-Mini',
description: 'Compact and powerful for complex tasks',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 1000,
deployment: 'gpt-4o-mini-se',
},
isActive: true,
},
{
id: '550e8400-e29b-41d4-a716-446655440005',
name: 'GPT-4o',
description: 'Most advanced model for demanding tasks',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 2000,
deployment: 'gpt-4o-se',
},
isActive: true,
},
];
await db.insert(models).values(modelData);
console.log(`Seeded ${modelData.length} AI models successfully!`);
// Log the seeded models
const seededModels = await db.select().from(models);
console.log('Seeded models:');
seededModels.forEach((model) => {
console.log(` - ${model.name} (${model.id})`);
});
} catch (error) {
console.error('Error seeding database:', error);
throw error;
} finally {
await client.end();
}
}
// Run seed
seed()
.then(() => {
console.log('Seed completed!');
process.exit(0);
})
.catch((error) => {
console.error('Seed failed:', error);
process.exit(1);
});

View file

@ -0,0 +1,129 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { DocumentService } from './document.service';
import { type Document } from '../db/schema/documents.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
@Controller('documents')
@UseGuards(JwtAuthGuard)
export class DocumentController {
constructor(private readonly documentService: DocumentService) {}
@Get('conversation/:conversationId')
async getLatestDocument(
@Param('conversationId') conversationId: string,
@CurrentUser() user: CurrentUserData,
): Promise<Document | null> {
const result = await this.documentService.getLatestDocument(
conversationId,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('conversation/:conversationId/versions')
async getAllDocumentVersions(
@Param('conversationId') conversationId: string,
@CurrentUser() user: CurrentUserData,
): Promise<Document[]> {
const result = await this.documentService.getAllDocumentVersions(
conversationId,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('conversation/:conversationId/exists')
async hasDocument(
@Param('conversationId') conversationId: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ exists: boolean }> {
const result = await this.documentService.hasDocument(
conversationId,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return { exists: result.value };
}
@Post('conversation/:conversationId')
async createDocument(
@Param('conversationId') conversationId: string,
@Body() body: { content: string },
@CurrentUser() user: CurrentUserData,
): Promise<Document> {
const result = await this.documentService.createDocument(
conversationId,
user.userId,
body.content,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Post('conversation/:conversationId/version')
async createDocumentVersion(
@Param('conversationId') conversationId: string,
@Body() body: { content: string },
@CurrentUser() user: CurrentUserData,
): Promise<Document> {
const result = await this.documentService.createDocumentVersion(
conversationId,
user.userId,
body.content,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id')
async deleteDocumentVersion(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.documentService.deleteDocumentVersion(
id,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DocumentController } from './document.controller';
import { DocumentService } from './document.service';
@Module({
controllers: [DocumentController],
providers: [DocumentService],
exports: [DocumentService],
})
export class DocumentModule {}

View file

@ -0,0 +1,239 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
documents,
type Document,
type NewDocument,
} from '../db/schema/documents.schema';
import { conversations } from '../db/schema/conversations.schema';
@Injectable()
export class DocumentService {
private readonly logger = new Logger(DocumentService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
private async verifyConversationOwnership(
conversationId: string,
userId: string,
): AsyncResult<void> {
const result = await this.db
.select()
.from(conversations)
.where(
and(
eq(conversations.id, conversationId),
eq(conversations.userId, userId),
),
)
.limit(1);
if (result.length === 0) {
return err(new NotFoundError('Conversation', conversationId));
}
return ok(undefined);
}
async createDocument(
conversationId: string,
userId: string,
content: string,
): AsyncResult<Document> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
const newDocument: NewDocument = {
conversationId,
version: 1,
content,
};
const result = await this.db
.insert(documents)
.values(newDocument)
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating document', error);
return err(DatabaseError.queryFailed('Failed to create document'));
}
}
async createDocumentVersion(
conversationId: string,
userId: string,
content: string,
): AsyncResult<Document> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
// Get the latest version number
const latestDoc = await this.db
.select({ version: documents.version })
.from(documents)
.where(eq(documents.conversationId, conversationId))
.orderBy(desc(documents.version))
.limit(1);
const newVersion = (latestDoc[0]?.version || 0) + 1;
const newDocument: NewDocument = {
conversationId,
version: newVersion,
content,
};
const result = await this.db
.insert(documents)
.values(newDocument)
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating document version', error);
return err(DatabaseError.queryFailed('Failed to create document version'));
}
}
async getLatestDocument(
conversationId: string,
userId: string,
): AsyncResult<Document | null> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
const result = await this.db
.select()
.from(documents)
.where(eq(documents.conversationId, conversationId))
.orderBy(desc(documents.version))
.limit(1);
return ok(result.length > 0 ? result[0] : null);
} catch (error) {
this.logger.error('Error fetching latest document', error);
return err(DatabaseError.queryFailed('Failed to fetch latest document'));
}
}
async getAllDocumentVersions(
conversationId: string,
userId: string,
): AsyncResult<Document[]> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
const result = await this.db
.select()
.from(documents)
.where(eq(documents.conversationId, conversationId))
.orderBy(desc(documents.version));
return ok(result);
} catch (error) {
this.logger.error('Error fetching document versions', error);
return err(DatabaseError.queryFailed('Failed to fetch document versions'));
}
}
async hasDocument(
conversationId: string,
userId: string,
): AsyncResult<boolean> {
try {
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(documents)
.where(eq(documents.conversationId, conversationId));
return ok(Number(result[0]?.count || 0) > 0);
} catch (error) {
this.logger.error('Error checking document existence', error);
return err(DatabaseError.queryFailed('Failed to check document existence'));
}
}
async deleteDocumentVersion(
documentId: string,
userId: string,
): AsyncResult<void> {
try {
// Get the document to verify ownership
const doc = await this.db
.select()
.from(documents)
.where(eq(documents.id, documentId))
.limit(1);
if (doc.length === 0) {
return err(new NotFoundError('Document', documentId));
}
// Verify conversation ownership
const ownershipResult = await this.verifyConversationOwnership(
doc[0].conversationId,
userId,
);
if (!ownershipResult.ok) {
return err(ownershipResult.error);
}
await this.db.delete(documents).where(eq(documents.id, documentId));
return ok(undefined);
} catch (error) {
this.logger.error('Error deleting document version', error);
return err(DatabaseError.queryFailed('Failed to delete document version'));
}
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'chat-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,40 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
app.enableCors({
origin: [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:8081',
'exp://localhost:8081',
'http://localhost:3001', // Mana Core Auth
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Global exception filter will be added later via module
// app.useGlobalFilters(new AppExceptionFilter());
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3002;
await app.listen(port);
console.log(`Chat backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,33 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { ModelService } from './model.service';
import { type Model } from '../db/schema/models.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@Controller('models')
@UseGuards(JwtAuthGuard)
export class ModelController {
constructor(private readonly modelService: ModelService) {}
@Get()
async getModels(): Promise<Model[]> {
const result = await this.modelService.getModels();
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id')
async getModel(@Param('id') id: string): Promise<Model> {
const result = await this.modelService.getModel(id);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ModelController } from './model.controller';
import { ModelService } from './model.service';
@Module({
controllers: [ModelController],
providers: [ModelService],
exports: [ModelService],
})
export class ModelModule {}

View file

@ -0,0 +1,55 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, asc } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { models, type Model } from '../db/schema/models.schema';
@Injectable()
export class ModelService {
private readonly logger = new Logger(ModelService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
async getModels(): AsyncResult<Model[]> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.isActive, true))
.orderBy(asc(models.name));
return ok(result);
} catch (error) {
this.logger.error('Error fetching models', error);
return err(DatabaseError.queryFailed('Failed to fetch models'));
}
}
async getModel(id: string): AsyncResult<Model> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.id, id))
.limit(1);
if (result.length === 0) {
return err(new NotFoundError('Model', id));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error fetching model', error);
return err(DatabaseError.queryFailed('Failed to fetch model'));
}
}
}

View file

@ -0,0 +1,219 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { SpaceService } from './space.service';
import { type Space, type SpaceMember } from '../db/schema/spaces.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
@Controller('spaces')
@UseGuards(JwtAuthGuard)
export class SpaceController {
constructor(private readonly spaceService: SpaceService) {}
@Get()
async getUserSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
const result = await this.spaceService.getUserSpaces(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('owned')
async getOwnedSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
const result = await this.spaceService.getOwnedSpaces(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('invitations')
async getPendingInvitations(
@CurrentUser() user: CurrentUserData,
): Promise<Array<{ invitation: SpaceMember; space: Space }>> {
const result = await this.spaceService.getPendingInvitations(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id')
async getSpace(@Param('id') id: string): Promise<Space> {
const result = await this.spaceService.getSpace(id);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id/members')
async getSpaceMembers(
@Param('id') id: string,
): Promise<SpaceMember[]> {
const result = await this.spaceService.getSpaceMembers(id);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id/role')
async getUserRoleInSpace(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ role: 'owner' | 'admin' | 'member' | 'viewer' | null }> {
const result = await this.spaceService.getUserRoleInSpace(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return { role: result.value };
}
@Post()
async createSpace(
@Body() body: { name: string; description?: string },
@CurrentUser() user: CurrentUserData,
): Promise<Space> {
const result = await this.spaceService.createSpace(
user.userId,
body.name,
body.description,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id')
async updateSpace(
@Param('id') id: string,
@Body() body: { name?: string; description?: string; isArchived?: boolean },
@CurrentUser() user: CurrentUserData,
): Promise<Space> {
const result = await this.spaceService.updateSpace(id, user.userId, body);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id')
async deleteSpace(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.spaceService.deleteSpace(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
@Post(':id/invite')
async inviteUser(
@Param('id') id: string,
@Body() body: { userId: string; role?: 'admin' | 'member' | 'viewer' },
@CurrentUser() user: CurrentUserData,
): Promise<SpaceMember> {
const result = await this.spaceService.inviteUserToSpace(
id,
body.userId,
user.userId,
body.role,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Post(':id/respond')
async respondToInvitation(
@Param('id') id: string,
@Body() body: { status: 'accepted' | 'declined' },
@CurrentUser() user: CurrentUserData,
): Promise<SpaceMember> {
const result = await this.spaceService.respondToInvitation(
id,
user.userId,
body.status,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id/members/:userId')
async removeMember(
@Param('id') id: string,
@Param('userId') userId: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.spaceService.removeMember(id, userId, user.userId);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
@Patch(':id/members/:userId/role')
async changeMemberRole(
@Param('id') id: string,
@Param('userId') userId: string,
@Body() body: { role: 'admin' | 'member' | 'viewer' },
@CurrentUser() user: CurrentUserData,
): Promise<SpaceMember> {
const result = await this.spaceService.changeMemberRole(
id,
userId,
body.role,
user.userId,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SpaceController } from './space.controller';
import { SpaceService } from './space.service';
@Module({
controllers: [SpaceController],
providers: [SpaceService],
exports: [SpaceService],
})
export class SpaceModule {}

View file

@ -0,0 +1,449 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, desc, inArray } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
spaces,
spaceMembers,
type Space,
type NewSpace,
type SpaceMember,
type NewSpaceMember,
} from '../db/schema/spaces.schema';
@Injectable()
export class SpaceService {
private readonly logger = new Logger(SpaceService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
async getUserSpaces(userId: string): AsyncResult<Space[]> {
try {
// Get all space IDs where user is an accepted member
const memberData = await this.db
.select({ spaceId: spaceMembers.spaceId })
.from(spaceMembers)
.where(
and(
eq(spaceMembers.userId, userId),
eq(spaceMembers.invitationStatus, 'accepted'),
),
);
if (memberData.length === 0) {
return ok([]);
}
const spaceIds = memberData.map((m) => m.spaceId);
const result = await this.db
.select()
.from(spaces)
.where(and(inArray(spaces.id, spaceIds), eq(spaces.isArchived, false)))
.orderBy(desc(spaces.createdAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching user spaces', error);
return err(DatabaseError.queryFailed('Failed to fetch user spaces'));
}
}
async getOwnedSpaces(userId: string): AsyncResult<Space[]> {
try {
const result = await this.db
.select()
.from(spaces)
.where(and(eq(spaces.ownerId, userId), eq(spaces.isArchived, false)))
.orderBy(desc(spaces.createdAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching owned spaces', error);
return err(DatabaseError.queryFailed('Failed to fetch owned spaces'));
}
}
async getSpace(id: string): AsyncResult<Space> {
try {
const result = await this.db
.select()
.from(spaces)
.where(eq(spaces.id, id))
.limit(1);
if (result.length === 0) {
return err(new NotFoundError('Space', id));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error fetching space', error);
return err(DatabaseError.queryFailed('Failed to fetch space'));
}
}
async createSpace(
userId: string,
name: string,
description?: string,
): AsyncResult<Space> {
try {
const newSpace: NewSpace = {
ownerId: userId,
name,
description,
isArchived: false,
};
const result = await this.db
.insert(spaces)
.values(newSpace)
.returning();
// Add owner as an accepted member
const memberData: NewSpaceMember = {
spaceId: result[0].id,
userId,
role: 'owner',
invitationStatus: 'accepted',
joinedAt: new Date(),
};
await this.db.insert(spaceMembers).values(memberData);
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating space', error);
return err(DatabaseError.queryFailed('Failed to create space'));
}
}
async updateSpace(
id: string,
userId: string,
data: { name?: string; description?: string; isArchived?: boolean },
): AsyncResult<Space> {
try {
// Verify ownership
const spaceResult = await this.getSpace(id);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
if (spaceResult.value.ownerId !== userId) {
return err(new NotFoundError('Space', id));
}
const result = await this.db
.update(spaces)
.set({ ...data, updatedAt: new Date() })
.where(eq(spaces.id, id))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error updating space', error);
return err(DatabaseError.queryFailed('Failed to update space'));
}
}
async deleteSpace(id: string, userId: string): AsyncResult<void> {
try {
// Verify ownership
const spaceResult = await this.getSpace(id);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
if (spaceResult.value.ownerId !== userId) {
return err(new NotFoundError('Space', id));
}
// Members will be cascade deleted
await this.db.delete(spaces).where(eq(spaces.id, id));
return ok(undefined);
} catch (error) {
this.logger.error('Error deleting space', error);
return err(DatabaseError.queryFailed('Failed to delete space'));
}
}
async getSpaceMembers(spaceId: string): AsyncResult<SpaceMember[]> {
try {
const result = await this.db
.select()
.from(spaceMembers)
.where(eq(spaceMembers.spaceId, spaceId))
.orderBy(spaceMembers.role, desc(spaceMembers.joinedAt));
return ok(result);
} catch (error) {
this.logger.error('Error fetching space members', error);
return err(DatabaseError.queryFailed('Failed to fetch space members'));
}
}
async inviteUserToSpace(
spaceId: string,
userId: string,
invitedByUserId: string,
role: 'admin' | 'member' | 'viewer' = 'member',
): AsyncResult<SpaceMember> {
try {
// Check if user is already a member
const existingMember = await this.db
.select()
.from(spaceMembers)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
),
)
.limit(1);
if (existingMember.length > 0) {
if (existingMember[0].invitationStatus === 'accepted') {
return ok(existingMember[0]);
}
// Update existing invitation
const result = await this.db
.update(spaceMembers)
.set({
role,
invitationStatus: 'pending',
invitedBy: invitedByUserId,
invitedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(spaceMembers.id, existingMember[0].id))
.returning();
return ok(result[0]);
}
// Create new invitation
const memberData: NewSpaceMember = {
spaceId,
userId,
role,
invitationStatus: 'pending',
invitedBy: invitedByUserId,
};
const result = await this.db
.insert(spaceMembers)
.values(memberData)
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error inviting user to space', error);
return err(DatabaseError.queryFailed('Failed to invite user to space'));
}
}
async respondToInvitation(
spaceId: string,
userId: string,
status: 'accepted' | 'declined',
): AsyncResult<SpaceMember> {
try {
const updates: Partial<SpaceMember> = {
invitationStatus: status,
updatedAt: new Date(),
};
if (status === 'accepted') {
updates.joinedAt = new Date();
}
const result = await this.db
.update(spaceMembers)
.set(updates)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
),
)
.returning();
if (result.length === 0) {
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error responding to invitation', error);
return err(DatabaseError.queryFailed('Failed to respond to invitation'));
}
}
async removeMember(
spaceId: string,
userId: string,
requestingUserId: string,
): AsyncResult<void> {
try {
// Verify the requesting user is the owner or an admin
const spaceResult = await this.getSpace(spaceId);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
const requestingMember = await this.db
.select()
.from(spaceMembers)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, requestingUserId),
),
)
.limit(1);
const isOwner = spaceResult.value.ownerId === requestingUserId;
const isAdmin =
requestingMember.length > 0 && requestingMember[0].role === 'admin';
if (!isOwner && !isAdmin) {
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
}
await this.db
.delete(spaceMembers)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
),
);
return ok(undefined);
} catch (error) {
this.logger.error('Error removing member', error);
return err(DatabaseError.queryFailed('Failed to remove member'));
}
}
async changeMemberRole(
spaceId: string,
userId: string,
newRole: 'admin' | 'member' | 'viewer',
requestingUserId: string,
): AsyncResult<SpaceMember> {
try {
// Verify the requesting user is the owner
const spaceResult = await this.getSpace(spaceId);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
if (spaceResult.value.ownerId !== requestingUserId) {
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
}
const result = await this.db
.update(spaceMembers)
.set({ role: newRole, updatedAt: new Date() })
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
),
)
.returning();
if (result.length === 0) {
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error changing member role', error);
return err(DatabaseError.queryFailed('Failed to change member role'));
}
}
async getUserRoleInSpace(
spaceId: string,
userId: string,
): AsyncResult<'owner' | 'admin' | 'member' | 'viewer' | null> {
try {
// Check if owner
const spaceResult = await this.getSpace(spaceId);
if (!spaceResult.ok) {
return err(spaceResult.error);
}
if (spaceResult.value.ownerId === userId) {
return ok('owner');
}
// Check membership
const memberResult = await this.db
.select()
.from(spaceMembers)
.where(
and(
eq(spaceMembers.spaceId, spaceId),
eq(spaceMembers.userId, userId),
eq(spaceMembers.invitationStatus, 'accepted'),
),
)
.limit(1);
if (memberResult.length === 0) {
return ok(null);
}
return ok(memberResult[0].role as 'admin' | 'member' | 'viewer');
} catch (error) {
this.logger.error('Error getting user role in space', error);
return err(DatabaseError.queryFailed('Failed to get user role in space'));
}
}
async getPendingInvitations(
userId: string,
): AsyncResult<Array<{ invitation: SpaceMember; space: Space }>> {
try {
const invitations = await this.db
.select()
.from(spaceMembers)
.where(
and(
eq(spaceMembers.userId, userId),
eq(spaceMembers.invitationStatus, 'pending'),
),
);
const results: Array<{ invitation: SpaceMember; space: Space }> = [];
for (const invitation of invitations) {
const spaceResult = await this.getSpace(invitation.spaceId);
if (spaceResult.ok) {
results.push({ invitation, space: spaceResult.value });
}
}
return ok(results);
} catch (error) {
this.logger.error('Error fetching pending invitations', error);
return err(DatabaseError.queryFailed('Failed to fetch pending invitations'));
}
}
}

View file

@ -0,0 +1,141 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { isOk } from '@manacore/shared-errors';
import { TemplateService } from './template.service';
import { type Template } from '../db/schema/templates.schema';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
@Controller('templates')
@UseGuards(JwtAuthGuard)
export class TemplateController {
constructor(private readonly templateService: TemplateService) {}
@Get()
async getTemplates(@CurrentUser() user: CurrentUserData): Promise<Template[]> {
const result = await this.templateService.getTemplates(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get('default')
async getDefaultTemplate(
@CurrentUser() user: CurrentUserData,
): Promise<Template | null> {
const result = await this.templateService.getDefaultTemplate(user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Get(':id')
async getTemplate(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Template> {
const result = await this.templateService.getTemplate(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Post()
async createTemplate(
@Body()
body: {
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color?: string;
documentMode?: boolean;
},
@CurrentUser() user: CurrentUserData,
): Promise<Template> {
const result = await this.templateService.createTemplate(user.userId, body);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id')
async updateTemplate(
@Param('id') id: string,
@Body()
body: Partial<{
name: string;
description: string;
systemPrompt: string;
initialQuestion: string;
modelId: string;
color: string;
documentMode: boolean;
}>,
@CurrentUser() user: CurrentUserData,
): Promise<Template> {
const result = await this.templateService.updateTemplate(
id,
user.userId,
body,
);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id/default')
async setDefaultTemplate(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<Template> {
const result = await this.templateService.setDefaultTemplate(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id')
async deleteTemplate(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
): Promise<{ success: boolean }> {
const result = await this.templateService.deleteTemplate(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TemplateController } from './template.controller';
import { TemplateService } from './template.service';
@Module({
controllers: [TemplateController],
providers: [TemplateService],
exports: [TemplateService],
})
export class TemplateModule {}

View file

@ -0,0 +1,191 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, asc } from 'drizzle-orm';
import {
type AsyncResult,
ok,
err,
DatabaseError,
NotFoundError,
} from '@manacore/shared-errors';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
templates,
type Template,
type NewTemplate,
} from '../db/schema/templates.schema';
@Injectable()
export class TemplateService {
private readonly logger = new Logger(TemplateService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
) {}
async getTemplates(userId: string): AsyncResult<Template[]> {
try {
const result = await this.db
.select()
.from(templates)
.where(eq(templates.userId, userId))
.orderBy(asc(templates.name));
return ok(result);
} catch (error) {
this.logger.error('Error fetching templates', error);
return err(DatabaseError.queryFailed('Failed to fetch templates'));
}
}
async getTemplate(id: string, userId: string): AsyncResult<Template> {
try {
const result = await this.db
.select()
.from(templates)
.where(and(eq(templates.id, id), eq(templates.userId, userId)))
.limit(1);
if (result.length === 0) {
return err(new NotFoundError('Template', id));
}
return ok(result[0]);
} catch (error) {
this.logger.error('Error fetching template', error);
return err(DatabaseError.queryFailed('Failed to fetch template'));
}
}
async getDefaultTemplate(userId: string): AsyncResult<Template | null> {
try {
const result = await this.db
.select()
.from(templates)
.where(
and(eq(templates.userId, userId), eq(templates.isDefault, true)),
)
.limit(1);
return ok(result.length > 0 ? result[0] : null);
} catch (error) {
this.logger.error('Error fetching default template', error);
return err(DatabaseError.queryFailed('Failed to fetch default template'));
}
}
async createTemplate(
userId: string,
data: {
name: string;
description?: string;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
color?: string;
documentMode?: boolean;
},
): AsyncResult<Template> {
try {
const newTemplate: NewTemplate = {
userId,
name: data.name,
description: data.description,
systemPrompt: data.systemPrompt,
initialQuestion: data.initialQuestion,
modelId: data.modelId,
color: data.color || '#3b82f6',
documentMode: data.documentMode || false,
isDefault: false,
};
const result = await this.db
.insert(templates)
.values(newTemplate)
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error creating template', error);
return err(DatabaseError.queryFailed('Failed to create template'));
}
}
async updateTemplate(
id: string,
userId: string,
data: Partial<{
name: string;
description: string;
systemPrompt: string;
initialQuestion: string;
modelId: string;
color: string;
documentMode: boolean;
}>,
): AsyncResult<Template> {
try {
// First verify the template belongs to the user
const templateResult = await this.getTemplate(id, userId);
if (!templateResult.ok) {
return err(templateResult.error);
}
const result = await this.db
.update(templates)
.set({ ...data, updatedAt: new Date() })
.where(eq(templates.id, id))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error updating template', error);
return err(DatabaseError.queryFailed('Failed to update template'));
}
}
async setDefaultTemplate(id: string, userId: string): AsyncResult<Template> {
try {
// First verify the template belongs to the user
const templateResult = await this.getTemplate(id, userId);
if (!templateResult.ok) {
return err(templateResult.error);
}
// Clear all default flags for this user
await this.db
.update(templates)
.set({ isDefault: false, updatedAt: new Date() })
.where(eq(templates.userId, userId));
// Set the new default
const result = await this.db
.update(templates)
.set({ isDefault: true, updatedAt: new Date() })
.where(eq(templates.id, id))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error setting default template', error);
return err(DatabaseError.queryFailed('Failed to set default template'));
}
}
async deleteTemplate(id: string, userId: string): AsyncResult<void> {
try {
// First verify the template belongs to the user
const templateResult = await this.getTemplate(id, userId);
if (!templateResult.ok) {
return err(templateResult.error);
}
await this.db.delete(templates).where(eq(templates.id, id));
return ok(undefined);
} catch (error) {
this.logger.error('Error deleting template', error);
return err(DatabaseError.queryFailed('Failed to delete template'));
}
}
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

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,80 @@
---
const footerLinks = {
product: [
{ href: '#features', label: 'Features' },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' }
],
legal: [
{ href: '/privacy', label: 'Datenschutz' },
{ href: '/terms', label: 'AGB' },
{ href: '/imprint', label: 'Impressum' }
]
};
const currentYear = new Date().getFullYear();
---
<footer class="bg-background-card border-t border-border">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<a href="/" class="flex items-center gap-2 mb-4">
<svg class="w-8 h-8 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
<span class="font-bold text-xl text-text-primary">ManaChat</span>
</a>
<p class="text-text-secondary text-sm max-w-md">
Dein intelligenter KI-Chat-Assistent. Chatte mit GPT-4o, GPT-4o-Mini und mehr -
alles in einer einfachen, eleganten Oberfläche.
</p>
</div>
<!-- Product Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
<ul class="space-y-2">
{footerLinks.product.map(link => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
<!-- Legal Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
<ul class="space-y-2">
{footerLinks.legal.map(link => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))}
</ul>
</div>
</div>
<!-- Bottom -->
<div class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4">
<p class="text-text-muted text-sm">
&copy; {currentYear} ManaChat. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">
Made with 💙 in Germany
</p>
</div>
</div>
</footer>

View file

@ -0,0 +1,86 @@
---
const navLinks = [
{ href: '#features', label: 'Features' },
{ href: '#how-it-works', label: 'So funktioniert\'s' },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' }
];
---
<nav class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center gap-2">
<svg class="w-8 h-8 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
<span class="font-bold text-xl text-text-primary">ManaChat</span>
</a>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
{navLinks.map(link => (
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
>
{link.label}
</a>
))}
</div>
<!-- CTA Button -->
<div class="flex items-center gap-4">
<a
href="#download"
class="btn-primary text-sm px-4 py-2"
>
App herunterladen
</a>
<!-- Mobile Menu Button -->
<button
type="button"
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
aria-label="Menu"
id="mobile-menu-button"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
<div class="hidden md:hidden" id="mobile-menu">
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
{navLinks.map(link => (
<a
href={link.href}
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
>
{link.label}
</a>
))}
</div>
</div>
</nav>
<script>
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
mobileMenuButton?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
// Close menu when clicking a link
mobileMenu?.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
mobileMenu?.classList.add('hidden');
});
});
</script>

View file

@ -0,0 +1,47 @@
---
import '../styles/global.css';
interface Props {
title: string;
description?: string;
}
const {
title,
description = 'ManaChat - Dein intelligenter KI-Chat-Assistent mit GPT-4o und mehr'
} = Astro.props;
---
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="generator" content={Astro.generator} />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:locale" content="de_DE" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">
<slot />
</body>
</html>

View file

@ -0,0 +1,259 @@
---
import Layout from '../layouts/Layout.astro';
import Navigation from '../components/Navigation.astro';
import Footer from '../components/Footer.astro';
// Shared components
import HeroSection from '@manacore/shared-landing-ui/sections/HeroSection.astro';
import FeatureSection from '@manacore/shared-landing-ui/sections/FeatureSection.astro';
import StepsSection from '@manacore/shared-landing-ui/sections/StepsSection.astro';
import FAQSection from '@manacore/shared-landing-ui/sections/FAQSection.astro';
import CTASection from '@manacore/shared-landing-ui/sections/CTASection.astro';
import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.astro';
// Feature data
const features = [
{
icon: '🤖',
title: 'Mehrere KI-Modelle',
description: 'Wähle zwischen GPT-4o, GPT-4o-Mini und weiteren leistungsstarken Modellen für deine Gespräche.'
},
{
icon: '💬',
title: 'Konversationen speichern',
description: 'Alle deine Chats werden sicher in der Cloud gespeichert und sind jederzeit abrufbar.'
},
{
icon: '📱',
title: 'Plattformübergreifend',
description: 'Nutze ManaChat auf iOS, Android und im Web - deine Daten sind überall synchronisiert.'
},
{
icon: '📝',
title: 'Dokument-Modus',
description: 'Arbeite mit der KI an längeren Texten im speziellen Dokument-Modus mit Versionierung.'
},
{
icon: '🎨',
title: 'Vorlagen',
description: 'Nutze vorgefertigte Vorlagen für häufige Aufgaben wie Texte schreiben, Code erklären oder Übersetzungen.'
},
{
icon: '🔒',
title: 'Privatsphäre',
description: 'Deine Daten sind sicher. Wir verkaufen keine Nutzerdaten und sind DSGVO-konform.'
}
];
// Steps data
const steps = [
{
number: '1',
title: 'App herunterladen',
description: 'Lade ManaChat kostenlos im App Store oder Google Play Store herunter.',
image: '/screenshots/download.png'
},
{
number: '2',
title: 'Konto erstellen',
description: 'Registriere dich in wenigen Sekunden mit E-Mail oder Google-Account.',
image: '/screenshots/register.png'
},
{
number: '3',
title: 'Loslegen',
description: 'Starte dein erstes Gespräch mit der KI - einfach und intuitiv.',
image: '/screenshots/chat.png'
}
];
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
{ text: '50 Nachrichten/Tag', included: true },
{ text: 'GPT-4o-Mini Zugang', included: true },
{ text: 'Chat-Verlauf speichern', included: true },
{ text: 'Basis-Vorlagen', included: true },
{ text: 'GPT-4o Zugang', included: false },
{ text: 'Dokument-Modus', included: false }
],
cta: {
text: 'Kostenlos starten',
href: '#download'
}
},
{
name: 'Pro',
price: '9,99',
period: '/Monat',
description: 'Für Power-User',
features: [
{ text: 'Unbegrenzte Nachrichten', included: true },
{ text: 'Alle KI-Modelle', included: true },
{ text: 'Dokument-Modus', included: true },
{ text: 'Alle Vorlagen', included: true },
{ text: 'Prioritäts-Antworten', included: true },
{ text: 'Premium-Support', included: true }
],
cta: {
text: 'Pro werden',
href: '#download'
},
highlighted: true,
badge: 'Beliebt'
},
{
name: 'Team',
price: '24,99',
period: '/Monat',
description: 'Für Teams und Unternehmen',
features: [
{ text: 'Alles aus Pro', included: true },
{ text: 'Team-Verwaltung', included: true },
{ text: 'Geteilte Chats', included: true },
{ text: 'Admin-Dashboard', included: true },
{ text: 'API-Zugang', included: true },
{ text: 'Dedizierter Support', included: true }
],
cta: {
text: 'Team starten',
href: '#download'
}
}
];
// FAQ data
const faqs = [
{
question: 'Welche KI-Modelle sind verfügbar?',
answer: 'ManaChat bietet Zugang zu GPT-4o, GPT-4o-Mini und weiteren Modellen. Du kannst das Modell für jedes Gespräch individuell auswählen, je nach Komplexität deiner Anfrage.'
},
{
question: 'Wie sicher sind meine Daten?',
answer: 'Deine Daten werden verschlüsselt übertragen und gespeichert. Wir verkaufen keine Nutzerdaten an Dritte und sind vollständig DSGVO-konform. Du kannst deine Daten jederzeit exportieren oder löschen.'
},
{
question: 'Was ist der Dokument-Modus?',
answer: 'Im Dokument-Modus kannst du mit der KI an längeren Texten arbeiten. Die KI hilft dir beim Schreiben, Überarbeiten und Verbessern. Alle Änderungen werden versioniert, sodass du jederzeit frühere Versionen wiederherstellen kannst.'
},
{
question: 'Kann ich ManaChat offline nutzen?',
answer: 'Da ManaChat auf Cloud-KI-Modellen basiert, ist eine Internetverbindung erforderlich. Dein Chat-Verlauf wird jedoch lokal zwischengespeichert und synchronisiert, sobald du wieder online bist.'
},
{
question: 'Wie funktioniert die Synchronisierung?',
answer: 'Alle deine Chats werden automatisch in der Cloud gespeichert und sind auf allen deinen Geräten verfügbar. Melde dich einfach mit dem gleichen Account an und du hast sofort Zugriff auf alle Gespräche.'
},
{
question: 'Kann ich mein Abo jederzeit kündigen?',
answer: 'Ja, du kannst dein Pro- oder Team-Abo jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Premium-Features.'
}
];
---
<Layout title="ManaChat - Dein intelligenter KI-Chat-Assistent">
<Navigation />
<main class="pt-16">
<HeroSection
title="Chatte mit den besten KI-Modellen"
subtitle="ManaChat gibt dir Zugang zu GPT-4o, GPT-4o-Mini und mehr. Eine elegante App für intelligente Gespräche - auf allen deinen Geräten."
variant="default"
primaryCta={{
text: 'Jetzt kostenlos starten',
href: '#download'
}}
secondaryCta={{
text: 'Features entdecken',
href: '#features',
variant: 'secondary'
}}
trustBadges={[
{ icon: '✓', text: 'Kostenlos testen' },
{ icon: '🔒', text: 'DSGVO-konform' },
{ icon: '📱', text: 'iOS, Android & Web' }
]}
/>
<FeatureSection
id="features"
title="Alles was du für KI-Chats brauchst"
subtitle="ManaChat kombiniert die besten KI-Modelle mit einer intuitiven Oberfläche für maximale Produktivität."
features={features}
columns={3}
variant="cards"
class="bg-[var(--color-background-card)]"
/>
<StepsSection
id="how-it-works"
title="In 3 Schritten loslegen"
subtitle="So einfach startest du mit ManaChat"
steps={steps}
showImages={false}
alternateLayout={true}
/>
<PricingSection
id="pricing"
title="Wähle deinen Plan"
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
plans={pricingPlans}
class="bg-[var(--color-background-card)]"
/>
<FAQSection
id="faq"
title="Häufig gestellte Fragen"
subtitle="Alles was du über ManaChat wissen musst"
faqs={faqs}
/>
<CTASection
id="download"
title="Bereit für intelligente Gespräche?"
subtitle="Lade ManaChat jetzt herunter und starte dein erstes Gespräch mit GPT-4o. Kostenlos und ohne Kreditkarte."
primaryCta={{ text: 'App herunterladen', href: '#' }}
variant="highlighted"
>
<!-- App Store Buttons -->
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
</a>
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
</a>
</div>
<!-- Trust Indicators -->
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
</div>
</div>
</CTASection>
</main>
<Footer />
</Layout>

View file

@ -0,0 +1,103 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ManaChat Theme CSS Variables - Sky Blue */
:root {
/* Primary colors - ManaChat Sky Blue */
--color-primary: #0ea5e9;
--color-primary-hover: #38bdf8;
--color-primary-glow: rgba(14, 165, 233, 0.3);
/* Text colors */
--color-text-primary: #f9fafb;
--color-text-secondary: #d1d5db;
--color-text-muted: #6b7280;
/* Background colors */
--color-background-page: #0c1929;
--color-background-card: #142236;
--color-background-card-hover: #1e3a50;
/* Border colors */
--color-border: #1e3a50;
--color-border-hover: #2d5a73;
}
/* Base styles */
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--color-background-page);
color: var(--color-text-primary);
line-height: 1.6;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-background-card);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border-hover);
}
/* Selection */
::selection {
background-color: var(--color-primary);
color: white;
}
/* Focus styles */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Gradient text */
.text-gradient {
background: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animation utilities */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.6s ease-out forwards;
}
/* Button styles */
.btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 bg-primary text-white font-semibold rounded-lg transition-all duration-200;
@apply hover:bg-primary-hover hover:shadow-lg hover:shadow-primary-glow;
}
.btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 border border-border text-text-primary font-semibold rounded-lg transition-all duration-200;
@apply hover:border-border-hover hover:bg-background-card;
}

View file

@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
],
theme: {
extend: {
colors: {
// ManaChat Sky Blue Theme
primary: {
DEFAULT: '#0ea5e9',
hover: '#38bdf8',
glow: 'rgba(14, 165, 233, 0.3)'
},
background: {
page: '#0c1929',
card: '#142236',
'card-hover': '#1e3a50'
},
text: {
primary: '#f9fafb',
secondary: '#d1d5db',
muted: '#6b7280'
},
border: {
DEFAULT: '#1e3a50',
hover: '#2d5a73'
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif']
}
}
},
plugins: [
require('@tailwindcss/typography')
]
};

View file

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

View file

@ -0,0 +1,10 @@
# Mana Core Auth Configuration
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# Supabase Configuration (for database only, not auth)
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:3002

25
apps/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
apps/chat/apps/mobile/app-env.d.ts vendored Normal file
View file

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

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

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