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

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

View file

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

View file

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

View file

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

View file

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

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