mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 14:57:42 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -1,12 +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,
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +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"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,19 +10,19 @@ 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,
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
ChatModule,
|
||||
ConversationModule,
|
||||
TemplateModule,
|
||||
SpaceModule,
|
||||
DocumentModule,
|
||||
ModelModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,31 @@
|
|||
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 { ChatCompletionDto, ChatCompletionResponseDto } from './dto/chat-completion.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import {
|
||||
CurrentUser,
|
||||
CurrentUserData,
|
||||
} from '../common/decorators/current-user.decorator';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('chat')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ChatController {
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
|
||||
@Get('models')
|
||||
async getModels() {
|
||||
return this.chatService.getAvailableModels();
|
||||
}
|
||||
@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);
|
||||
@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
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error; // Caught by AppExceptionFilter
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { ChatController } from './chat.controller';
|
|||
import { ChatService } from './chat.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService],
|
||||
exports: [ChatService],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService],
|
||||
exports: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
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 { 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';
|
||||
|
|
@ -15,154 +9,134 @@ import { ChatCompletionDto, ChatCompletionResponseDto } from './dto/chat-complet
|
|||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
private readonly logger = new Logger(ChatService.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly endpoint: string;
|
||||
private readonly apiVersion: string;
|
||||
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';
|
||||
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!');
|
||||
}
|
||||
}
|
||||
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 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 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);
|
||||
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`),
|
||||
);
|
||||
}
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
// 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 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;
|
||||
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,
|
||||
})),
|
||||
};
|
||||
// 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');
|
||||
// Model-specific parameters
|
||||
const isGPTOModel = deployment.includes('gpt-o') || deployment.includes('gpt-4o');
|
||||
|
||||
if (!isGPTOModel) {
|
||||
requestBody.max_tokens = maxTokens;
|
||||
requestBody.temperature = temperature;
|
||||
}
|
||||
if (!isGPTOModel) {
|
||||
requestBody.max_tokens = maxTokens;
|
||||
requestBody.temperature = temperature;
|
||||
}
|
||||
|
||||
const url = `${this.endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${this.apiVersion}`;
|
||||
const url = `${this.endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${this.apiVersion}`;
|
||||
|
||||
this.logger.log(`Sending request to: ${url}`);
|
||||
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),
|
||||
});
|
||||
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}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
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 data = await response.json();
|
||||
|
||||
const messageContent = data.choices?.[0]?.message?.content;
|
||||
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'),
|
||||
);
|
||||
}
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,47 @@
|
|||
import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
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()
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class ChatCompletionDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageDto)
|
||||
messages: ChatMessageDto[];
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageDto)
|
||||
messages: ChatMessageDto[];
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
modelId: string;
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
modelId: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
temperature?: number;
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
temperature?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
maxTokens?: number;
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export class ChatCompletionResponseDto {
|
||||
content: string;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
content: string;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserData {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
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;
|
||||
},
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,66 +1,60 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
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';
|
||||
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 }),
|
||||
});
|
||||
// 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');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
const { valid, payload } = await response.json();
|
||||
const { valid, payload } = await response.json();
|
||||
|
||||
if (!valid || !payload) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
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,
|
||||
};
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,211 +1,181 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
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';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('conversations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ConversationController {
|
||||
constructor(private readonly conversationService: ConversationService) {}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('archived')
|
||||
async getArchivedConversations(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Conversation[]> {
|
||||
const result = await this.conversationService.getArchivedConversations(
|
||||
user.userId,
|
||||
);
|
||||
@Get('archived')
|
||||
async getArchivedConversations(@CurrentUser() user: CurrentUserData): Promise<Conversation[]> {
|
||||
const result = await this.conversationService.getArchivedConversations(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
},
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { ConversationController } from './conversation.controller';
|
|||
import { ConversationService } from './conversation.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ConversationController],
|
||||
providers: [ConversationService],
|
||||
exports: [ConversationService],
|
||||
controllers: [ConversationController],
|
||||
providers: [ConversationService],
|
||||
exports: [ConversationService],
|
||||
})
|
||||
export class ConversationModule {}
|
||||
|
|
|
|||
|
|
@ -1,319 +1,270 @@
|
|||
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 { 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,
|
||||
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);
|
||||
private readonly logger = new Logger(ConversationService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
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),
|
||||
];
|
||||
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));
|
||||
}
|
||||
if (spaceId) {
|
||||
conditions.push(eq(conversations.spaceId, spaceId));
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(conversations.updatedAt));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
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));
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
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();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 newMessage: NewMessage = {
|
||||
conversationId,
|
||||
sender,
|
||||
messageText,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(messages)
|
||||
.values(newMessage)
|
||||
.returning();
|
||||
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));
|
||||
// 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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
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));
|
||||
}
|
||||
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();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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));
|
||||
// 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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,30 +9,30 @@ 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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
|
|
|
|||
|
|
@ -6,23 +6,23 @@ 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],
|
||||
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();
|
||||
}
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,24 +6,24 @@ import { getDb, closeConnection } from './connection';
|
|||
config();
|
||||
|
||||
async function runMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
|
||||
console.log('Running migrations...');
|
||||
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();
|
||||
}
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -9,34 +9,34 @@ 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(),
|
||||
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),
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -3,21 +3,21 @@ 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(),
|
||||
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],
|
||||
}),
|
||||
conversation: one(conversations, {
|
||||
fields: [documents.conversationId],
|
||||
references: [conversations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Document = typeof documents.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -5,21 +5,21 @@ 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(),
|
||||
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],
|
||||
}),
|
||||
conversation: one(conversations, {
|
||||
fields: [messages.conversationId],
|
||||
references: [conversations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Message = typeof messages.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
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(),
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -2,42 +2,46 @@ import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-
|
|||
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 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(),
|
||||
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(),
|
||||
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),
|
||||
members: many(spaceMembers),
|
||||
}));
|
||||
|
||||
export const spaceMembersRelations = relations(spaceMembers, ({ one }) => ({
|
||||
space: one(spaces, {
|
||||
fields: [spaceMembers.spaceId],
|
||||
references: [spaces.id],
|
||||
}),
|
||||
space: one(spaces, {
|
||||
fields: [spaceMembers.spaceId],
|
||||
references: [spaces.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Space = typeof spaces.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -3,25 +3,25 @@ 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(),
|
||||
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],
|
||||
}),
|
||||
model: one(models, {
|
||||
fields: [templates.modelId],
|
||||
references: [models.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Template = typeof templates.$inferSelect;
|
||||
|
|
|
|||
|
|
@ -5,35 +5,35 @@ 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(),
|
||||
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],
|
||||
}),
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -9,92 +9,92 @@ import * as dotenv from 'dotenv';
|
|||
|
||||
dotenv.config();
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || 'postgresql://chat:password@localhost:5432/chat';
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL || 'postgresql://chat:password@localhost:5432/chat';
|
||||
|
||||
async function seed() {
|
||||
console.log('Starting database seed...');
|
||||
console.log('Starting database seed...');
|
||||
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client);
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client);
|
||||
|
||||
try {
|
||||
// Check if models already exist
|
||||
const existingModels = await db.select().from(models);
|
||||
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;
|
||||
}
|
||||
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...');
|
||||
// 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,
|
||||
},
|
||||
];
|
||||
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);
|
||||
await db.insert(models).values(modelData);
|
||||
|
||||
console.log(`Seeded ${modelData.length} AI models successfully!`);
|
||||
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();
|
||||
}
|
||||
// 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);
|
||||
});
|
||||
.then(() => {
|
||||
console.log('Seed completed!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Seed failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,129 +1,106 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('documents')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DocumentController {
|
||||
constructor(private readonly documentService: DocumentService) {}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { exists: result.value };
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { DocumentController } from './document.controller';
|
|||
import { DocumentService } from './document.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DocumentController],
|
||||
providers: [DocumentService],
|
||||
exports: [DocumentService],
|
||||
controllers: [DocumentController],
|
||||
providers: [DocumentService],
|
||||
exports: [DocumentService],
|
||||
})
|
||||
export class DocumentModule {}
|
||||
|
|
|
|||
|
|
@ -1,239 +1,186 @@
|
|||
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 { 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 { 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);
|
||||
private readonly logger = new Logger(DocumentService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
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);
|
||||
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));
|
||||
}
|
||||
if (result.length === 0) {
|
||||
return err(new NotFoundError('Conversation', conversationId));
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 newDocument: NewDocument = {
|
||||
conversationId,
|
||||
version: 1,
|
||||
content,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(documents)
|
||||
.values(newDocument)
|
||||
.returning();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
// 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 newVersion = (latestDoc[0]?.version || 0) + 1;
|
||||
|
||||
const newDocument: NewDocument = {
|
||||
conversationId,
|
||||
version: newVersion,
|
||||
content,
|
||||
};
|
||||
const newDocument: NewDocument = {
|
||||
conversationId,
|
||||
version: newVersion,
|
||||
content,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(documents)
|
||||
.values(newDocument)
|
||||
.returning();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
// 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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error('Error deleting document version', error);
|
||||
return err(DatabaseError.queryFailed('Failed to delete document version'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { Controller, Get } from '@nestjs/common';
|
|||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'chat-backend',
|
||||
};
|
||||
}
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'chat-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
|
|
@ -3,38 +3,38 @@ import { ValidationPipe } from '@nestjs/common';
|
|||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
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,
|
||||
});
|
||||
// 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());
|
||||
// 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,
|
||||
}),
|
||||
);
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api');
|
||||
// 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}`);
|
||||
const port = process.env.PORT || 3002;
|
||||
await app.listen(port);
|
||||
console.log(`Chat backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -7,27 +7,27 @@ import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
|||
@Controller('models')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ModelController {
|
||||
constructor(private readonly modelService: ModelService) {}
|
||||
constructor(private readonly modelService: ModelService) {}
|
||||
|
||||
@Get()
|
||||
async getModels(): Promise<Model[]> {
|
||||
const result = await this.modelService.getModels();
|
||||
@Get()
|
||||
async getModels(): Promise<Model[]> {
|
||||
const result = await this.modelService.getModels();
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getModel(@Param('id') id: string): Promise<Model> {
|
||||
const result = await this.modelService.getModel(id);
|
||||
@Get(':id')
|
||||
async getModel(@Param('id') id: string): Promise<Model> {
|
||||
const result = await this.modelService.getModel(id);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { ModelController } from './model.controller';
|
|||
import { ModelService } from './model.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ModelController],
|
||||
providers: [ModelService],
|
||||
exports: [ModelService],
|
||||
controllers: [ModelController],
|
||||
providers: [ModelService],
|
||||
exports: [ModelService],
|
||||
})
|
||||
export class ModelModule {}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,43 @@
|
|||
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 { 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);
|
||||
private readonly logger = new Logger(ModelService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
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));
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching model', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch model'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,219 +1,192 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('spaces')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SpaceController {
|
||||
constructor(private readonly spaceService: SpaceService) {}
|
||||
constructor(private readonly spaceService: SpaceService) {}
|
||||
|
||||
@Get()
|
||||
async getUserSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
|
||||
const result = await this.spaceService.getUserSpaces(user.userId);
|
||||
@Get()
|
||||
async getUserSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
|
||||
const result = await this.spaceService.getUserSpaces(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('owned')
|
||||
async getOwnedSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
|
||||
const result = await this.spaceService.getOwnedSpaces(user.userId);
|
||||
@Get('owned')
|
||||
async getOwnedSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
|
||||
const result = await this.spaceService.getOwnedSpaces(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('invitations')
|
||||
async getPendingInvitations(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Array<{ invitation: SpaceMember; space: Space }>> {
|
||||
const result = await this.spaceService.getPendingInvitations(user.userId);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getSpace(@Param('id') id: string): Promise<Space> {
|
||||
const result = await this.spaceService.getSpace(id);
|
||||
@Get(':id')
|
||||
async getSpace(@Param('id') id: string): Promise<Space> {
|
||||
const result = await this.spaceService.getSpace(id);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id/members')
|
||||
async getSpaceMembers(
|
||||
@Param('id') id: string,
|
||||
): Promise<SpaceMember[]> {
|
||||
const result = await this.spaceService.getSpaceMembers(id);
|
||||
@Get(':id/members')
|
||||
async getSpaceMembers(@Param('id') id: string): Promise<SpaceMember[]> {
|
||||
const result = await this.spaceService.getSpaceMembers(id);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { role: result.value };
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { SpaceController } from './space.controller';
|
|||
import { SpaceService } from './space.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SpaceController],
|
||||
providers: [SpaceService],
|
||||
exports: [SpaceService],
|
||||
controllers: [SpaceController],
|
||||
providers: [SpaceService],
|
||||
exports: [SpaceService],
|
||||
})
|
||||
export class SpaceModule {}
|
||||
|
|
|
|||
|
|
@ -1,449 +1,387 @@
|
|||
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 { 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,
|
||||
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);
|
||||
private readonly logger = new Logger(SpaceService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
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'),
|
||||
),
|
||||
);
|
||||
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([]);
|
||||
}
|
||||
if (memberData.length === 0) {
|
||||
return ok([]);
|
||||
}
|
||||
|
||||
const spaceIds = memberData.map((m) => m.spaceId);
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
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));
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
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();
|
||||
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(),
|
||||
};
|
||||
// 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);
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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));
|
||||
}
|
||||
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();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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));
|
||||
}
|
||||
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));
|
||||
// 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'));
|
||||
}
|
||||
}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
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]);
|
||||
}
|
||||
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();
|
||||
// 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]);
|
||||
}
|
||||
return ok(result[0]);
|
||||
}
|
||||
|
||||
// Create new invitation
|
||||
const memberData: NewSpaceMember = {
|
||||
spaceId,
|
||||
userId,
|
||||
role,
|
||||
invitationStatus: 'pending',
|
||||
invitedBy: invitedByUserId,
|
||||
};
|
||||
// Create new invitation
|
||||
const memberData: NewSpaceMember = {
|
||||
spaceId,
|
||||
userId,
|
||||
role,
|
||||
invitationStatus: 'pending',
|
||||
invitedBy: invitedByUserId,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(spaceMembers)
|
||||
.values(memberData)
|
||||
.returning();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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(),
|
||||
};
|
||||
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();
|
||||
}
|
||||
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();
|
||||
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}`));
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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 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';
|
||||
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}`));
|
||||
}
|
||||
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),
|
||||
),
|
||||
);
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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}`));
|
||||
}
|
||||
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();
|
||||
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}`));
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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');
|
||||
}
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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'),
|
||||
),
|
||||
);
|
||||
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 }> = [];
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
return ok(results);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching pending invitations', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch pending invitations'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,141 +1,123 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('templates')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TemplateController {
|
||||
constructor(private readonly templateService: TemplateService) {}
|
||||
constructor(private readonly templateService: TemplateService) {}
|
||||
|
||||
@Get()
|
||||
async getTemplates(@CurrentUser() user: CurrentUserData): Promise<Template[]> {
|
||||
const result = await this.templateService.getTemplates(user.userId);
|
||||
@Get()
|
||||
async getTemplates(@CurrentUser() user: CurrentUserData): Promise<Template[]> {
|
||||
const result = await this.templateService.getTemplates(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('default')
|
||||
async getDefaultTemplate(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Template | null> {
|
||||
const result = await this.templateService.getDefaultTemplate(user.userId);
|
||||
@Get('default')
|
||||
async getDefaultTemplate(@CurrentUser() user: CurrentUserData): Promise<Template | null> {
|
||||
const result = await this.templateService.getDefaultTemplate(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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,
|
||||
);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
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);
|
||||
@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;
|
||||
}
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { TemplateController } from './template.controller';
|
|||
import { TemplateService } from './template.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TemplateController],
|
||||
providers: [TemplateService],
|
||||
exports: [TemplateService],
|
||||
controllers: [TemplateController],
|
||||
providers: [TemplateService],
|
||||
exports: [TemplateService],
|
||||
})
|
||||
export class TemplateModule {}
|
||||
|
|
|
|||
|
|
@ -1,191 +1,174 @@
|
|||
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 { 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';
|
||||
import { templates, type Template, type NewTemplate } from '../db/schema/templates.schema';
|
||||
|
||||
@Injectable()
|
||||
export class TemplateService {
|
||||
private readonly logger = new Logger(TemplateService.name);
|
||||
private readonly logger = new Logger(TemplateService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
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));
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
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();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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));
|
||||
// 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();
|
||||
// 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'));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
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));
|
||||
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'));
|
||||
}
|
||||
}
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error('Error deleting template', error);
|
||||
return err(DatabaseError.queryFailed('Failed to delete template'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +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"]
|
||||
"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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +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"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +1,94 @@
|
|||
---
|
||||
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' }
|
||||
]
|
||||
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>
|
||||
<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"
|
||||
></path>
|
||||
</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>
|
||||
<!-- 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>
|
||||
<!-- 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">
|
||||
© {currentYear} ManaChat. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">
|
||||
Made with 💙 in Germany
|
||||
</p>
|
||||
</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">
|
||||
© {currentYear} ManaChat. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">Made with 💙 in Germany</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,86 +1,101 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#how-it-works', label: 'So funktioniert\'s' },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' }
|
||||
{ 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>
|
||||
<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"
|
||||
></path>
|
||||
</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>
|
||||
<!-- 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>
|
||||
<!-- 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 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>
|
||||
<!-- 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');
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
// Close menu when clicking a link
|
||||
mobileMenu?.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
mobileMenu?.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,46 +2,49 @@
|
|||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'ManaChat - Dein intelligenter KI-Chat-Assistent mit GPT-4o und mehr'
|
||||
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} />
|
||||
<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" />
|
||||
<!-- 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" />
|
||||
<!-- 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} />
|
||||
<!-- 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" />
|
||||
<!-- 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>
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -13,247 +13,264 @@ import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.
|
|||
|
||||
// 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.'
|
||||
}
|
||||
{
|
||||
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'
|
||||
}
|
||||
{
|
||||
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'
|
||||
}
|
||||
}
|
||||
{
|
||||
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.'
|
||||
}
|
||||
{
|
||||
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 />
|
||||
<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' }
|
||||
]}
|
||||
/>
|
||||
<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)]"
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
<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)]"
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
<!-- 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 />
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Claude's Guide to Chat Mobile App
|
||||
|
||||
## Commands
|
||||
|
||||
- Start app: `pnpm dev` or `pnpm start`
|
||||
- iOS: `pnpm ios`
|
||||
- Android: `pnpm android`
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
## 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
|
||||
|
|
@ -19,12 +21,14 @@
|
|||
- `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
|
||||
|
|
|
|||
|
|
@ -21,20 +21,24 @@ Eine moderne mobile Chat-Anwendung zur Interaktion mit verschiedenen KI-Sprachmo
|
|||
## 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
|
||||
|
|
@ -53,6 +57,7 @@ Eine moderne mobile Chat-Anwendung zur Interaktion mit verschiedenen KI-Sprachmo
|
|||
## Nutzung
|
||||
|
||||
Nach dem Start kannst du:
|
||||
|
||||
- Dich registrieren oder anmelden
|
||||
- Ein KI-Modell auswählen
|
||||
- Eine neue Konversation starten
|
||||
|
|
@ -60,4 +65,4 @@ Nach dem Start kannst du:
|
|||
|
||||
## Lizenz
|
||||
|
||||
MIT
|
||||
MIT
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Basierend auf der Codeanalyse schlage ich folgende Maßnahmen zur Vereinfachung
|
|||
|
||||
## 3. Architektur-Optimierung
|
||||
|
||||
- **State Management**:
|
||||
- **State Management**:
|
||||
- Auth-Zustand über einen zentralen Store verwalten
|
||||
- Modell- und Konversationszustand aus UI-Komponenten in Services verlagern
|
||||
|
||||
|
|
@ -52,4 +52,4 @@ Basierend auf der Codeanalyse schlage ich folgende Maßnahmen zur Vereinfachung
|
|||
2. API-Wrapper erstellen
|
||||
3. State Management umstellen
|
||||
4. UI-Komponenten vereinheitlichen
|
||||
5. Styling standardisieren
|
||||
5. Styling standardisieren
|
||||
|
|
|
|||
|
|
@ -3,36 +3,43 @@
|
|||
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
|
||||
4. **Bessere Wartbarkeit**: klare Trennung zwischen Datenzugriff und UI
|
||||
|
|
|
|||
|
|
@ -1,56 +1,54 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,75 +5,75 @@ 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',
|
||||
},
|
||||
};
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,34 +5,34 @@ import { ScrollViewStyleReset } from 'expo-router/html';
|
|||
// 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" />
|
||||
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"
|
||||
/>
|
||||
{/*
|
||||
<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 />
|
||||
<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>
|
||||
);
|
||||
{/* 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 = `
|
||||
|
|
|
|||
|
|
@ -4,21 +4,21 @@ 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>
|
||||
</>
|
||||
);
|
||||
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]`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,64 +9,64 @@ 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)',
|
||||
// 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>
|
||||
);
|
||||
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();
|
||||
const { user, loading } = useAuth();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
const inAuthGroup = segments[0] === 'auth';
|
||||
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]);
|
||||
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}</>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
<Layout />
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
<Layout />
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,97 +2,100 @@ const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:300
|
|||
|
||||
// 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;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: Record<string, any>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
// Fallback-Modelle, falls keine aus dem Backend 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'
|
||||
}
|
||||
}
|
||||
{
|
||||
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 vom Backend zu laden
|
||||
let models: Model[] = FALLBACK_MODELS;
|
||||
try {
|
||||
// Versuche, Modelle vom Backend zu laden
|
||||
let models: Model[] = FALLBACK_MODELS;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/chat/models`);
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/chat/models`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
models = data as Model[];
|
||||
}
|
||||
} else {
|
||||
console.error('Fehler beim Laden der Modelle vom Backend:', response.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler bei der Backend-Verbindung:', e);
|
||||
// Fallback zu den vordefinierten Modellen
|
||||
}
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.length > 0) {
|
||||
models = data as Model[];
|
||||
}
|
||||
} else {
|
||||
console.error('Fehler beim Laden der Modelle vom Backend:', response.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler bei der Backend-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',
|
||||
},
|
||||
});
|
||||
}
|
||||
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 (nicht unterstützt ohne Backend-Endpoint)
|
||||
export async function POST(request: Request) {
|
||||
return new Response(JSON.stringify({ error: 'Modell-Erstellung wird über das Backend nicht unterstützt' }), {
|
||||
status: 501,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Modell-Erstellung wird über das Backend nicht unterstützt' }),
|
||||
{
|
||||
status: 501,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,99 +3,99 @@
|
|||
|
||||
// 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;
|
||||
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;
|
||||
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;
|
||||
message_id: string;
|
||||
created_at: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
estimated_cost: number;
|
||||
};
|
||||
|
||||
// Handler für GET /api/usage
|
||||
// TODO: Backend-Endpoints für Usage-Statistiken implementieren
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get('userId');
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get('userId');
|
||||
|
||||
if (!userId) {
|
||||
return new Response(JSON.stringify({ error: 'User ID ist erforderlich' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (!userId) {
|
||||
return new Response(JSON.stringify({ error: 'User ID ist erforderlich' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Usage-Statistiken sind noch nicht über die Backend-API verfügbar
|
||||
// Gebe leere Daten zurück
|
||||
console.log('Usage-Statistiken: Backend-Endpoints noch nicht implementiert');
|
||||
// Usage-Statistiken sind noch nicht über die Backend-API verfügbar
|
||||
// Gebe leere Daten zurück
|
||||
console.log('Usage-Statistiken: Backend-Endpoints noch nicht implementiert');
|
||||
|
||||
return Response.json({
|
||||
modelUsage: [],
|
||||
periodUsage: [],
|
||||
summary: {
|
||||
totalCost: 0,
|
||||
totalTokens: 0
|
||||
},
|
||||
message: 'Usage-Statistiken sind derzeit nicht verfügbar'
|
||||
});
|
||||
} 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' }
|
||||
});
|
||||
}
|
||||
return Response.json({
|
||||
modelUsage: [],
|
||||
periodUsage: [],
|
||||
summary: {
|
||||
totalCost: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
message: 'Usage-Statistiken sind derzeit nicht verfügbar',
|
||||
});
|
||||
} 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
|
||||
// TODO: Backend-Endpoints für Conversation-Usage implementieren
|
||||
export async function GET_conversation(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const conversationId = url.searchParams.get('conversationId');
|
||||
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' }
|
||||
});
|
||||
}
|
||||
if (!conversationId) {
|
||||
return new Response(JSON.stringify({ error: 'Conversation ID ist erforderlich' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Usage-Statistiken sind noch nicht über die Backend-API verfügbar
|
||||
// Gebe leere Daten zurück
|
||||
console.log('Conversation-Usage: Backend-Endpoints noch nicht implementiert');
|
||||
// Usage-Statistiken sind noch nicht über die Backend-API verfügbar
|
||||
// Gebe leere Daten zurück
|
||||
console.log('Conversation-Usage: Backend-Endpoints noch nicht implementiert');
|
||||
|
||||
return Response.json({
|
||||
conversationUsage: [],
|
||||
summary: {
|
||||
totalCost: 0,
|
||||
totalTokens: 0,
|
||||
messageCount: 0
|
||||
},
|
||||
message: 'Usage-Statistiken sind derzeit nicht verfügbar'
|
||||
});
|
||||
} 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' }
|
||||
});
|
||||
}
|
||||
return Response.json({
|
||||
conversationUsage: [],
|
||||
summary: {
|
||||
totalCost: 0,
|
||||
totalTokens: 0,
|
||||
messageCount: 0,
|
||||
},
|
||||
message: 'Usage-Statistiken sind derzeit nicht verfügbar',
|
||||
});
|
||||
} 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
ActivityIndicator
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useTheme, useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
|
@ -16,488 +16,481 @@ import { useAuth } from '../context/AuthProvider';
|
|||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import CustomDrawer from '../components/CustomDrawer';
|
||||
import {
|
||||
getArchivedConversations,
|
||||
getMessages,
|
||||
deleteConversation,
|
||||
unarchiveConversation
|
||||
getArchivedConversations,
|
||||
getMessages,
|
||||
deleteConversation,
|
||||
unarchiveConversation,
|
||||
} from '../services/conversation';
|
||||
import { modelApi } from '../services/api';
|
||||
|
||||
// Typendefinitionen für Konversationen
|
||||
type ConversationItem = {
|
||||
id: string;
|
||||
modelName: string;
|
||||
title: string;
|
||||
lastMessage: string;
|
||||
timestamp: Date;
|
||||
mode: 'frei' | 'geführt' | 'vorlage';
|
||||
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}`;
|
||||
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);
|
||||
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 über die Backend API
|
||||
const modelData = await modelApi.getModel(conv.model_id);
|
||||
// Eine Funktion, die Konversationen lädt und wiederverwendet werden kann
|
||||
const loadConversations = async () => {
|
||||
if (!user) return;
|
||||
|
||||
// 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];
|
||||
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()
|
||||
);
|
||||
|
||||
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])
|
||||
);
|
||||
// Lade für jede Konversation die letzte Nachricht und das Modell
|
||||
const conversationItems: ConversationItem[] = [];
|
||||
|
||||
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.");
|
||||
}
|
||||
};
|
||||
for (const conv of userConversations) {
|
||||
try {
|
||||
// Lade die Nachrichten der Konversation
|
||||
const messages = await getMessages(conv.id);
|
||||
// Lade das Modell über die Backend API
|
||||
const modelData = await modelApi.getModel(conv.model_id);
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
// 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];
|
||||
|
||||
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>
|
||||
);
|
||||
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',
|
||||
},
|
||||
});
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,5 @@ import React from 'react';
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
);
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
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';
|
||||
|
|
@ -7,252 +15,255 @@ import { useAuth } from '../../context/AuthProvider';
|
|||
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 { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { signIn } = useAuth();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse und dein Passwort ein.');
|
||||
return;
|
||||
}
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { error } = await signIn(email, password);
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse und dein Passwort ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Anmeldung fehlgeschlagen', error.message || 'Unbekannter Fehler');
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Magic Link ist derzeit nicht verfügbar (mana-core-auth unterstützt dies nicht)
|
||||
const handleMagicLink = async () => {
|
||||
Alert.alert(
|
||||
'Nicht verfügbar',
|
||||
'Magic Link Anmeldung ist derzeit nicht verfügbar. Bitte nutze E-Mail und Passwort.'
|
||||
);
|
||||
};
|
||||
try {
|
||||
setLoading(true);
|
||||
const { error } = await signIn(email, password);
|
||||
|
||||
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>
|
||||
);
|
||||
if (error) {
|
||||
Alert.alert('Anmeldung fehlgeschlagen', error.message || 'Unbekannter Fehler');
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Magic Link ist derzeit nicht verfügbar (mana-core-auth unterstützt dies nicht)
|
||||
const handleMagicLink = async () => {
|
||||
Alert.alert(
|
||||
'Nicht verfügbar',
|
||||
'Magic Link Anmeldung ist derzeit nicht verfügbar. Bitte nutze E-Mail und Passwort.'
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
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';
|
||||
|
|
@ -7,238 +15,243 @@ 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 { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { signUp } = useAuth();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(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 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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
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';
|
||||
|
|
@ -7,166 +15,166 @@ 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 { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { resetPassword } = useAuth();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(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 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',
|
||||
},
|
||||
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
|
|
@ -7,123 +7,119 @@ 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
|
||||
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);
|
||||
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");
|
||||
// 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;
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
console.error('Kein Benutzer gefunden');
|
||||
router.replace('/auth/login');
|
||||
return;
|
||||
}
|
||||
console.log('Erhaltene Parameter:', {
|
||||
initialMessage: initialMessage.substring(0, 50),
|
||||
modelId,
|
||||
templateId,
|
||||
mode,
|
||||
documentMode,
|
||||
spaceId: spaceId || 'nicht angegeben',
|
||||
});
|
||||
|
||||
if (!initialMessage) {
|
||||
console.warn('Keine Nachricht gefunden');
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
// Log für Debug-Zwecke
|
||||
console.log('⭐️ Neue Konversation wird erstellt mit Space ID:', spaceId || 'keine');
|
||||
|
||||
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
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
console.error('Kein Benutzer gefunden');
|
||||
router.replace('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error('Fehler beim Erstellen der Konversation');
|
||||
}
|
||||
if (!initialMessage) {
|
||||
console.warn('Keine Nachricht gefunden');
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Konversation erstellt mit ID:', conversationId);
|
||||
|
||||
// 2. Sende die initiale Nachricht
|
||||
const response = await sendMessageAndGetResponse(
|
||||
conversationId,
|
||||
initialMessage,
|
||||
modelId,
|
||||
templateId,
|
||||
documentMode
|
||||
);
|
||||
const startConversation = async () => {
|
||||
try {
|
||||
setIsFetching(true);
|
||||
console.log('Erstelle Konversation...');
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
// 1. Erstelle eine neue Konversation
|
||||
const conversationId = await createConversation(
|
||||
user.id,
|
||||
modelId,
|
||||
mode,
|
||||
templateId,
|
||||
documentMode,
|
||||
spaceId
|
||||
);
|
||||
|
||||
startConversation();
|
||||
}, [user, initialMessage, modelId, templateId, mode, documentMode, spaceId, router]);
|
||||
if (!conversationId) {
|
||||
throw new Error('Fehler beim Erstellen der Konversation');
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color="#0000ff" />
|
||||
<Text style={styles.text}>Starte Konversation...</Text>
|
||||
</View>
|
||||
);
|
||||
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,
|
||||
}
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
marginTop: 20,
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,13 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
Platform
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
|
@ -18,419 +18,428 @@ import { useAuth } from '../context/AuthProvider';
|
|||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
type DocumentWithTitle = Document & {
|
||||
conversation_title: string;
|
||||
conversation_title: string;
|
||||
};
|
||||
|
||||
export default function DocumentsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { width } = useWindowDimensions();
|
||||
const [documents, setDocuments] = useState<DocumentWithTitle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { width } = useWindowDimensions();
|
||||
const [documents, setDocuments] = useState<DocumentWithTitle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 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 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;
|
||||
// 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
|
||||
// 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]);
|
||||
return availableWidth * widthRatio;
|
||||
}, [width, columnsCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
loadDocuments();
|
||||
}
|
||||
}, [user]);
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
loadDocuments();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Lade alle Konversationen des Benutzers über die Backend-API
|
||||
const conversations = await conversationApi.getConversations();
|
||||
// Lade alle Konversationen des Benutzers über die Backend-API
|
||||
const conversations = await conversationApi.getConversations();
|
||||
|
||||
// Filtere nur Konversationen im Dokumentmodus
|
||||
const documentConversations = conversations.filter(conv => conv.documentMode);
|
||||
// Filtere nur Konversationen im Dokumentmodus
|
||||
const documentConversations = conversations.filter((conv) => conv.documentMode);
|
||||
|
||||
if (documentConversations.length === 0) {
|
||||
setDocuments([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (documentConversations.length === 0) {
|
||||
setDocuments([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Für jede Konversation den neuesten Dokumentstand laden
|
||||
const latestDocuments: DocumentWithTitle[] = [];
|
||||
// Für jede Konversation den neuesten Dokumentstand laden
|
||||
const latestDocuments: DocumentWithTitle[] = [];
|
||||
|
||||
for (const conv of documentConversations) {
|
||||
try {
|
||||
const docData = await getLatestDocument(conv.id);
|
||||
for (const conv of documentConversations) {
|
||||
try {
|
||||
const docData = await getLatestDocument(conv.id);
|
||||
|
||||
if (docData) {
|
||||
latestDocuments.push({
|
||||
...docData,
|
||||
conversation_title: conv.title || 'Unbenannte Konversation'
|
||||
});
|
||||
}
|
||||
} catch (docError) {
|
||||
console.error(`Fehler beim Laden des Dokuments für Konversation ${conv.id}:`, docError);
|
||||
}
|
||||
}
|
||||
if (docData) {
|
||||
latestDocuments.push({
|
||||
...docData,
|
||||
conversation_title: conv.title || 'Unbenannte Konversation',
|
||||
});
|
||||
}
|
||||
} catch (docError) {
|
||||
console.error(`Fehler beim Laden des Dokuments für Konversation ${conv.id}:`, docError);
|
||||
}
|
||||
}
|
||||
|
||||
setDocuments(latestDocuments);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Dokumente:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
setDocuments(latestDocuments);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Dokumente:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToConversation = (conversationId: string) => {
|
||||
router.push(`/conversation/${conversationId}`);
|
||||
};
|
||||
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}`;
|
||||
};
|
||||
// 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();
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,169 +10,164 @@ 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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
// Extrahiere mögliche Space ID aus den Parametern
|
||||
const spaceId = (params.spaceId as string) || null;
|
||||
|
||||
loadModels();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
// Lade Modelle vom Service
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const modelsList = await getModels();
|
||||
setModels(modelsList);
|
||||
|
||||
const handleSelectModel = (id: string) => {
|
||||
setSelectedModelId(id);
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
};
|
||||
loadModels();
|
||||
}, []);
|
||||
|
||||
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>
|
||||
const handleSelectModel = (id: string) => {
|
||||
setSelectedModelId(id);
|
||||
};
|
||||
|
||||
<Text style={[styles.subtitle, { color: colors.text + 'CC' }]}>
|
||||
Wähle das KI-Modell, mit dem du chatten möchtest
|
||||
</Text>
|
||||
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
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
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>
|
||||
|
||||
<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>
|
||||
);
|
||||
<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,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,16 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator, Pressable, Platform } from 'react-native';
|
||||
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';
|
||||
|
|
@ -7,497 +18,489 @@ 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>
|
||||
);
|
||||
};
|
||||
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);
|
||||
|
||||
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>
|
||||
);
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, TextInput, SafeAreaView, Alert, ActivityIndicator, ScrollView } from 'react-native';
|
||||
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';
|
||||
|
|
@ -7,208 +17,211 @@ 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);
|
||||
}
|
||||
};
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(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>
|
||||
);
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
Modal,
|
||||
ActivityIndicator
|
||||
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';
|
||||
|
|
@ -19,417 +19,399 @@ 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
|
||||
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 { 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,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
const plugins = [];
|
||||
api.cache(true);
|
||||
const plugins = [];
|
||||
|
||||
plugins.push('react-native-reanimated/plugin');
|
||||
plugins.push('react-native-reanimated/plugin');
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,40 +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"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,22 @@ import { forwardRef } from 'react';
|
|||
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
|
||||
|
||||
type ButtonProps = {
|
||||
title: string;
|
||||
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>
|
||||
);
|
||||
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',
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,89 +5,88 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { useRouter } from 'expo-router';
|
||||
|
||||
type ChatHeaderProps = {
|
||||
title?: string;
|
||||
modelName: string;
|
||||
conversationMode: string;
|
||||
onBackPress?: () => void;
|
||||
title?: string;
|
||||
modelName: string;
|
||||
conversationMode: string;
|
||||
onBackPress?: () => void;
|
||||
};
|
||||
|
||||
export default function ChatHeader({
|
||||
title,
|
||||
modelName,
|
||||
conversationMode,
|
||||
onBackPress
|
||||
export default function ChatHeader({
|
||||
title,
|
||||
modelName,
|
||||
conversationMode,
|
||||
onBackPress,
|
||||
}: ChatHeaderProps) {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
const handleBackPress = () => {
|
||||
if (onBackPress) {
|
||||
onBackPress();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
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>
|
||||
);
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,118 +5,101 @@ 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;
|
||||
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,
|
||||
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,
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View className="w-full px-4">
|
||||
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
|
||||
{showModelSelection && (
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<Text
|
||||
className={`text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}
|
||||
>
|
||||
Modell:
|
||||
</Text>
|
||||
<ModelDropdown selectedModelId={selectedModelId} onSelectModel={onSelectModel} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
className={`min-h-[40px] w-full rounded-lg px-4 py-2 text-base ${
|
||||
isDarkMode ? 'bg-[#1C1C1E] text-white' : 'bg-gray-100 text-black'
|
||||
}`}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<View className="mt-4 flex-row items-center justify-between">
|
||||
{(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 rounded-full px-3 py-2 ${
|
||||
canSend ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'
|
||||
}`}
|
||||
onPress={handleSend}
|
||||
disabled={!canSend}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-row items-center">
|
||||
<View className="mr-1 h-4 w-4">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import React, { useState, forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, ScrollView, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
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';
|
||||
|
|
@ -10,328 +18,344 @@ import { useAuth } from '../context/AuthProvider';
|
|||
import { Template, getTemplates } from '../services/template';
|
||||
|
||||
type ConversationStarterProps = {
|
||||
onSend?: (message: string) => void;
|
||||
placeholder?: string;
|
||||
onSend?: (message: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
// Definiere die Ref-Methoden, die von außen aufgerufen werden können
|
||||
export interface ConversationStarterRef {
|
||||
focus: () => void;
|
||||
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 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);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (text.trim()) {
|
||||
console.log("handleSend wird aufgerufen mit Text:", text.trim());
|
||||
// Expose methods via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Laden der Vorlagen beim ersten Rendern
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
if (!user) 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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const userTemplates = await getTemplates(user.id);
|
||||
setTemplates(userTemplates);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Vorlagen:', error);
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
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 max-w-3xl self-center px-4">
|
||||
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
|
||||
<View className="mb-3 flex-row items-center justify-between">
|
||||
<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={`min-h-[40px] w-full text-base ${isDarkMode ? 'text-white' : 'text-black'}`}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
/>
|
||||
|
||||
<View className="mt-4 flex-row items-center justify-between">
|
||||
<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 rounded-full px-3 py-2 ${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="mr-1 h-4 w-4">
|
||||
<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={`mb-1 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}
|
||||
>
|
||||
Vorlagen:
|
||||
</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="flex-row">
|
||||
{isLoadingTemplates ? (
|
||||
<View
|
||||
className={`mr-2 flex-row items-center justify-center rounded-full border px-3 py-1 ${
|
||||
isDarkMode ? 'border-[#38383A] bg-[#2C2C2E]' : 'border-[#E5E5EA] bg-white'
|
||||
}`}
|
||||
>
|
||||
<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={`mr-2 flex-row items-center rounded-full border px-3 py-1 ${
|
||||
selectedTemplate?.id === template.id
|
||||
? isDarkMode
|
||||
? 'bg-[#0A84FF]80 border-[#0A84FF]'
|
||||
: 'bg-[#0A84FF]40 border-[#0A84FF]'
|
||||
: isDarkMode
|
||||
? 'border-[#38383A] bg-[#2C2C2E]'
|
||||
: 'border-[#E5E5EA] bg-white'
|
||||
}`}
|
||||
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
|
||||
? 'font-medium text-white'
|
||||
: 'font-medium text-[#0A84FF]'
|
||||
: 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={`mr-2 flex-row items-center rounded-full border px-3 py-1 ${
|
||||
isDarkMode ? 'border-[#38383A] bg-[#2C2C2E]' : 'border-[#E5E5EA] bg-white'
|
||||
}`}
|
||||
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 rounded-full border px-3 py-1 ${
|
||||
isDarkMode ? 'border-[#38383A] bg-[#2C2C2E]' : 'border-[#E5E5EA] bg-white'
|
||||
}`}
|
||||
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,
|
||||
},
|
||||
chipIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export default ConversationStarter;
|
||||
export default ConversationStarter;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { SafeAreaView } from 'react-native';
|
||||
|
||||
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 m-6',
|
||||
container: 'flex flex-1 m-6',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import React, { useState, forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, ScrollView, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
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';
|
||||
|
|
@ -11,431 +19,458 @@ import { Template, getTemplates } from '../services/template';
|
|||
import { Space, getUserSpaces } from '../services/space';
|
||||
|
||||
type ConversationStarterProps = {
|
||||
onSend?: (message: string) => void;
|
||||
placeholder?: string;
|
||||
spaceId?: string | null;
|
||||
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;
|
||||
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]);
|
||||
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);
|
||||
|
||||
// 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();
|
||||
}
|
||||
};
|
||||
// Expose methods via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const handleSend = async () => {
|
||||
if (text.trim()) {
|
||||
console.log("handleSend wird aufgerufen mit Text:", text.trim());
|
||||
// Laden der Vorlagen und des aktuellen Space beim ersten Rendern
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
if (!user) return;
|
||||
|
||||
// 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;
|
||||
}
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const userTemplates = await getTemplates(user.id);
|
||||
setTemplates(userTemplates);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Vorlagen:', error);
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
loadTemplates();
|
||||
}, [user]);
|
||||
|
||||
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'}`}>
|
||||
// Laden des Space-Namens, wenn eine spaceId vorhanden ist
|
||||
useEffect(() => {
|
||||
const loadSpace = async () => {
|
||||
if (!spaceId) {
|
||||
setCurrentSpace(null);
|
||||
return;
|
||||
}
|
||||
|
||||
<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>
|
||||
try {
|
||||
const space = await getSpace(spaceId);
|
||||
setCurrentSpace(space);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Space:', error);
|
||||
setCurrentSpace(null);
|
||||
}
|
||||
};
|
||||
|
||||
<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>
|
||||
);
|
||||
});
|
||||
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 max-w-3xl self-center px-4">
|
||||
{/* 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="ml-auto flex-row items-center">
|
||||
<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={`min-h-[40px] w-full 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="mt-4 flex-row items-center justify-between">
|
||||
<View className="flex-row flex-wrap">
|
||||
<TouchableOpacity
|
||||
className={`mr-4 flex-row items-center rounded-md px-2 py-1 ${
|
||||
documentMode
|
||||
? 'bg-[#0A84FF]40 border border-[#0A84FF]'
|
||||
: isDarkMode
|
||||
? 'border border-[#38383A] bg-[#2C2C2E]'
|
||||
: 'border border-[#E5E5EA] bg-[#F2F2F7]'
|
||||
}`}
|
||||
onPress={() => setDocumentMode(!documentMode)}
|
||||
>
|
||||
<Ionicons
|
||||
name={documentMode ? 'document' : 'document-outline'}
|
||||
size={18}
|
||||
color={documentMode ? '#0A84FF' : isDarkMode ? '#FFFFFF' : '#000000'}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-1 ${documentMode ? 'font-medium text-[#0A84FF]' : isDarkMode ? 'text-white' : 'text-black'}`}
|
||||
>
|
||||
Dokument
|
||||
</Text>
|
||||
{documentMode && (
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={14}
|
||||
color="#0A84FF"
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity className="mr-4 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="mr-4 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 className="flex-row items-center">
|
||||
<ModelDropdown
|
||||
selectedModelId={selectedModelId}
|
||||
onSelectModel={setSelectedModelId}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center rounded-full px-3 py-2 ${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="mr-1 h-4 w-4">
|
||||
<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={`mb-1 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}
|
||||
>
|
||||
Vorlagen:
|
||||
</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="flex-row">
|
||||
{isLoadingTemplates ? (
|
||||
<View
|
||||
className={`mr-2 flex-row items-center justify-center rounded-full border px-3 py-1 ${
|
||||
isDarkMode ? 'border-[#38383A] bg-[#2C2C2E]' : 'border-[#E5E5EA] bg-white'
|
||||
}`}
|
||||
>
|
||||
<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={`mr-2 flex-row items-center rounded-full border px-3 py-1 ${
|
||||
selectedTemplate?.id === template.id
|
||||
? isDarkMode
|
||||
? 'bg-[#0A84FF]80 border-[#0A84FF]'
|
||||
: 'bg-[#0A84FF]40 border-[#0A84FF]'
|
||||
: isDarkMode
|
||||
? 'border-[#38383A] bg-[#2C2C2E]'
|
||||
: 'border-[#E5E5EA] bg-white'
|
||||
}`}
|
||||
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
|
||||
? 'font-medium text-white'
|
||||
: 'font-medium text-[#0A84FF]'
|
||||
: 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={`mr-2 flex-row items-center rounded-full border px-3 py-1 ${
|
||||
isDarkMode ? 'border-[#38383A] bg-[#2C2C2E]' : 'border-[#E5E5EA] bg-white'
|
||||
}`}
|
||||
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 rounded-full border px-3 py-1 ${
|
||||
isDarkMode ? 'border-[#38383A] bg-[#2C2C2E]' : 'border-[#E5E5EA] bg-white'
|
||||
}`}
|
||||
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,
|
||||
},
|
||||
chipIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export default ConversationStarter;
|
||||
export default ConversationStarter;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
StatusBar,
|
||||
ActivityIndicator,
|
||||
SafeAreaView,
|
||||
Platform
|
||||
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';
|
||||
|
|
@ -21,470 +21,459 @@ import { getConversations } from '../services/conversation';
|
|||
const DRAWER_WIDTH = 260; // Breite des Drawer-Menüs
|
||||
|
||||
interface CustomDrawerProps {
|
||||
isVisible: boolean;
|
||||
focusInputOnHomeNavigate?: () => void;
|
||||
onClose?: () => void;
|
||||
isVisible: boolean;
|
||||
focusInputOnHomeNavigate?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function CustomDrawer({
|
||||
isVisible,
|
||||
focusInputOnHomeNavigate,
|
||||
onClose
|
||||
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';
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { user, signOut } = useAuth();
|
||||
|
||||
// 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>
|
||||
const [recentChats, setRecentChats] = useState<{ id: string; title: string }[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
<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>
|
||||
// Lade die letzten Chats
|
||||
useEffect(() => {
|
||||
const loadRecentChats = async () => {
|
||||
if (!user || !isVisible) return;
|
||||
|
||||
<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>
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const conversations = await getConversations(user.id);
|
||||
|
||||
<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>
|
||||
);
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
Platform,
|
||||
Alert,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
|
@ -17,369 +17,353 @@ import { Document } from '../services/document';
|
|||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
interface DocumentPanelProps {
|
||||
document: Document | null;
|
||||
isLoading?: boolean;
|
||||
versionCount: number;
|
||||
onSave?: (content: string) => void;
|
||||
onShowVersions?: () => void;
|
||||
onNextVersion?: () => void;
|
||||
onPreviousVersion?: () => void;
|
||||
onDeleteVersion?: (document: Document) => void;
|
||||
document: Document | null;
|
||||
isLoading?: boolean;
|
||||
versionCount: number;
|
||||
onSave?: (content: string) => void;
|
||||
onShowVersions?: () => void;
|
||||
onNextVersion?: () => void;
|
||||
onPreviousVersion?: () => void;
|
||||
onDeleteVersion?: (document: Document) => void;
|
||||
}
|
||||
|
||||
// Hilfsfunktion, um zu prüfen, ob der Dark Mode aktiv ist
|
||||
const isDarkMode = (colors: any) => {
|
||||
return colors.background === '#000' ||
|
||||
colors.background === '#121212' ||
|
||||
colors.background.includes('rgba(0,0,0') ||
|
||||
colors.text === '#fff' ||
|
||||
colors.text === '#ffffff';
|
||||
return (
|
||||
colors.background === '#000' ||
|
||||
colors.background === '#121212' ||
|
||||
colors.background.includes('rgba(0,0,0') ||
|
||||
colors.text === '#fff' ||
|
||||
colors.text === '#ffffff'
|
||||
);
|
||||
};
|
||||
|
||||
export default function DocumentPanel({
|
||||
document,
|
||||
isLoading = false,
|
||||
versionCount,
|
||||
onSave,
|
||||
onShowVersions,
|
||||
onNextVersion,
|
||||
onPreviousVersion,
|
||||
onDeleteVersion
|
||||
document,
|
||||
isLoading = false,
|
||||
versionCount,
|
||||
onSave,
|
||||
onShowVersions,
|
||||
onNextVersion,
|
||||
onPreviousVersion,
|
||||
onDeleteVersion,
|
||||
}: DocumentPanelProps) {
|
||||
const { colors } = useTheme();
|
||||
const [content, setContent] = useState<string>(document?.content || '');
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
// Aktualisiere den Content, wenn sich das Dokument ändert
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
setContent(document.content);
|
||||
}
|
||||
}, [document]);
|
||||
const { colors } = useTheme();
|
||||
const [content, setContent] = useState<string>(document?.content || '');
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditing(true);
|
||||
};
|
||||
// Aktualisiere den Content, wenn sich das Dokument ändert
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
setContent(document.content);
|
||||
}
|
||||
}, [document]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setContent(document?.content || '');
|
||||
setEditing(false);
|
||||
};
|
||||
const handleEdit = () => {
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) {
|
||||
onSave(content);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
const handleCancel = () => {
|
||||
setContent(document?.content || '');
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const renderVersionControls = () => {
|
||||
// Aktuelle Version und Versionszählung
|
||||
const currentVersion = document?.version || 1;
|
||||
const hasMultipleVersions = versionCount > 1;
|
||||
const canGoBack = currentVersion > 1;
|
||||
const canGoForward = currentVersion < versionCount;
|
||||
|
||||
return (
|
||||
<View style={styles.versionControls}>
|
||||
{/* Pfeil zurück */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.versionArrow,
|
||||
!canGoBack && styles.versionArrowDisabled
|
||||
]}
|
||||
onPress={canGoBack ? onPreviousVersion : undefined}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={16}
|
||||
color={canGoBack ? '#666' : '#CCC'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Version Badge */}
|
||||
<TouchableOpacity
|
||||
style={styles.versionBadge}
|
||||
onPress={onShowVersions}
|
||||
>
|
||||
<Text style={styles.versionText}>v{currentVersion}</Text>
|
||||
{hasMultipleVersions && (
|
||||
<Text style={styles.versionCount}>{versionCount}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Pfeil vorwärts */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.versionArrow,
|
||||
!canGoForward && styles.versionArrowDisabled
|
||||
]}
|
||||
onPress={canGoForward ? onNextVersion : undefined}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={canGoForward ? '#666' : '#CCC'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const handleSave = () => {
|
||||
if (onSave) {
|
||||
onSave(content);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text }]}>
|
||||
Dokument wird geladen...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const renderVersionControls = () => {
|
||||
// Aktuelle Version und Versionszählung
|
||||
const currentVersion = document?.version || 1;
|
||||
const hasMultipleVersions = versionCount > 1;
|
||||
const canGoBack = currentVersion > 1;
|
||||
const canGoForward = currentVersion < versionCount;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
|
||||
{renderVersionControls()}
|
||||
<View style={styles.actions}>
|
||||
{editing ? (
|
||||
<>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleCancel}>
|
||||
<Ionicons name="close" size={22} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleSave}>
|
||||
<Ionicons name="checkmark" size={22} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{document && onDeleteVersion && versionCount > 1 && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => {
|
||||
if (document) {
|
||||
console.log('Löschen-Button in DocumentPanel gedrückt für Version:', document.version);
|
||||
|
||||
Alert.alert(
|
||||
"Version löschen",
|
||||
`Möchtest du die Version ${document.version} wirklich löschen?`,
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
style: "cancel"
|
||||
},
|
||||
{
|
||||
text: "Löschen",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
console.log('Löschvorgang bestätigt für Version:', document.version);
|
||||
if (onDeleteVersion) {
|
||||
onDeleteVersion(document);
|
||||
} else {
|
||||
console.error('onDeleteVersion Funktion ist nicht definiert');
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={22} color="#ff3b30" />
|
||||
<Text style={{fontSize: 10, color: '#ff3b30', marginLeft: 4}}>Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleEdit}>
|
||||
<Ionicons name="create-outline" size={22} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{editing ? (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.editor,
|
||||
{
|
||||
color: colors.text,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.border
|
||||
}
|
||||
]}
|
||||
multiline
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
autoFocus
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
) : (
|
||||
<ScrollView style={styles.contentContainer}>
|
||||
{document?.content ? (
|
||||
<Markdown
|
||||
style={{
|
||||
body: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
lineHeight: 22
|
||||
},
|
||||
heading1: {
|
||||
color: colors.text,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
paddingBottom: 8,
|
||||
marginBottom: 12
|
||||
},
|
||||
heading2: {
|
||||
color: colors.text,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border + '60',
|
||||
paddingBottom: 6,
|
||||
marginBottom: 10
|
||||
},
|
||||
heading3: { color: colors.text },
|
||||
heading4: { color: colors.text },
|
||||
heading5: { color: colors.text },
|
||||
heading6: { color: colors.text },
|
||||
paragraph: {
|
||||
color: colors.text,
|
||||
marginBottom: 12
|
||||
},
|
||||
list_item: { color: colors.text },
|
||||
blockquote: {
|
||||
backgroundColor: colors.card,
|
||||
borderLeftColor: colors.primary,
|
||||
borderLeftWidth: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
marginVertical: 8
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: isDarkMode(colors) ? '#1E1E1E' : '#F5F5F5',
|
||||
padding: 10,
|
||||
borderRadius: 6
|
||||
},
|
||||
link: { color: colors.primary }
|
||||
}}
|
||||
>
|
||||
{document.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<Text style={[styles.content, { color: colors.text }]}>
|
||||
Noch kein Dokument erstellt.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View style={styles.versionControls}>
|
||||
{/* Pfeil zurück */}
|
||||
<TouchableOpacity
|
||||
style={[styles.versionArrow, !canGoBack && styles.versionArrowDisabled]}
|
||||
onPress={canGoBack ? onPreviousVersion : undefined}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={16} color={canGoBack ? '#666' : '#CCC'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Version Badge */}
|
||||
<TouchableOpacity style={styles.versionBadge} onPress={onShowVersions}>
|
||||
<Text style={styles.versionText}>v{currentVersion}</Text>
|
||||
{hasMultipleVersions && <Text style={styles.versionCount}>{versionCount}</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Pfeil vorwärts */}
|
||||
<TouchableOpacity
|
||||
style={[styles.versionArrow, !canGoForward && styles.versionArrowDisabled]}
|
||||
onPress={canGoForward ? onNextVersion : undefined}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={16} color={canGoForward ? '#666' : '#CCC'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text }]}>Dokument wird geladen...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
|
||||
{renderVersionControls()}
|
||||
<View style={styles.actions}>
|
||||
{editing ? (
|
||||
<>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleCancel}>
|
||||
<Ionicons name="close" size={22} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleSave}>
|
||||
<Ionicons name="checkmark" size={22} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{document && onDeleteVersion && versionCount > 1 && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => {
|
||||
if (document) {
|
||||
console.log(
|
||||
'Löschen-Button in DocumentPanel gedrückt für Version:',
|
||||
document.version
|
||||
);
|
||||
|
||||
Alert.alert(
|
||||
'Version löschen',
|
||||
`Möchtest du die Version ${document.version} wirklich löschen?`,
|
||||
[
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
console.log('Löschvorgang bestätigt für Version:', document.version);
|
||||
if (onDeleteVersion) {
|
||||
onDeleteVersion(document);
|
||||
} else {
|
||||
console.error('onDeleteVersion Funktion ist nicht definiert');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={22} color="#ff3b30" />
|
||||
<Text style={{ fontSize: 10, color: '#ff3b30', marginLeft: 4 }}>Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleEdit}>
|
||||
<Ionicons name="create-outline" size={22} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{editing ? (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.editor,
|
||||
{
|
||||
color: colors.text,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
multiline
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
autoFocus
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
) : (
|
||||
<ScrollView style={styles.contentContainer}>
|
||||
{document?.content ? (
|
||||
<Markdown
|
||||
style={{
|
||||
body: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
heading1: {
|
||||
color: colors.text,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
paddingBottom: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
heading2: {
|
||||
color: colors.text,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border + '60',
|
||||
paddingBottom: 6,
|
||||
marginBottom: 10,
|
||||
},
|
||||
heading3: { color: colors.text },
|
||||
heading4: { color: colors.text },
|
||||
heading5: { color: colors.text },
|
||||
heading6: { color: colors.text },
|
||||
paragraph: {
|
||||
color: colors.text,
|
||||
marginBottom: 12,
|
||||
},
|
||||
list_item: { color: colors.text },
|
||||
blockquote: {
|
||||
backgroundColor: colors.card,
|
||||
borderLeftColor: colors.primary,
|
||||
borderLeftWidth: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
marginVertical: 8,
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: isDarkMode(colors) ? '#1E1E1E' : '#F5F5F5',
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
},
|
||||
link: { color: colors.primary },
|
||||
}}
|
||||
>
|
||||
{document.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<Text style={[styles.content, { color: colors.text }]}>
|
||||
Noch kein Dokument erstellt.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
versionControls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
versionArrow: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
versionArrowDisabled: {
|
||||
backgroundColor: 'rgba(0,0,0,0.02)',
|
||||
},
|
||||
versionBadge: {
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
versionText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#666',
|
||||
},
|
||||
versionCount: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
backgroundColor: '#666',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
marginLeft: 4,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
actionButton: {
|
||||
padding: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
paddingBottom: 60, // Extra padding für besseres Scrollen
|
||||
},
|
||||
content: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
editor: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
margin: 8,
|
||||
textAlignVertical: 'top',
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 10,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
versionControls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
versionArrow: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
versionArrowDisabled: {
|
||||
backgroundColor: 'rgba(0,0,0,0.02)',
|
||||
},
|
||||
versionBadge: {
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
versionText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#666',
|
||||
},
|
||||
versionCount: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
backgroundColor: '#666',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
marginLeft: 4,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
actionButton: {
|
||||
padding: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
paddingBottom: 60, // Extra padding für besseres Scrollen
|
||||
},
|
||||
content: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
editor: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
margin: 8,
|
||||
textAlignVertical: 'top',
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 10,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,243 +1,226 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Modal,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Modal,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Document } from '../services/document';
|
||||
|
||||
interface DocumentVersionsProps {
|
||||
isVisible: boolean;
|
||||
documents: Document[];
|
||||
onClose: () => void;
|
||||
onSelectVersion: (document: Document) => void;
|
||||
onDeleteVersion?: (document: Document) => void;
|
||||
isVisible: boolean;
|
||||
documents: Document[];
|
||||
onClose: () => void;
|
||||
onSelectVersion: (document: Document) => void;
|
||||
onDeleteVersion?: (document: Document) => void;
|
||||
}
|
||||
|
||||
export default function DocumentVersions({
|
||||
isVisible,
|
||||
documents,
|
||||
onClose,
|
||||
onSelectVersion,
|
||||
onDeleteVersion
|
||||
isVisible,
|
||||
documents,
|
||||
onClose,
|
||||
onSelectVersion,
|
||||
onDeleteVersion,
|
||||
}: DocumentVersionsProps) {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const renderVersionItem = (document: Document, isLatest: boolean) => {
|
||||
// Löschen nur anzeigen, wenn es mehr als eine Version gibt und es nicht die neueste ist
|
||||
// oder wenn es die einzige Version ist (nur zur Konsistenz)
|
||||
const canDelete = documents.length > 1 || !isLatest;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={document.id}
|
||||
style={[
|
||||
styles.versionItem,
|
||||
{ borderBottomColor: colors.border }
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{flex: 1}}
|
||||
activeOpacity={0.6}
|
||||
onPress={() => {
|
||||
console.log('Version auswählen:', document.id);
|
||||
onSelectVersion(document);
|
||||
}}
|
||||
>
|
||||
<View style={styles.versionHeader}>
|
||||
<View style={styles.versionBadge}>
|
||||
<Text style={styles.versionNumber}>v{document.version}</Text>
|
||||
</View>
|
||||
{isLatest && (
|
||||
<View style={[styles.latestBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.latestText}>Aktuell</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</View>
|
||||
|
||||
<Text style={[styles.versionDate, { color: colors.text + '99' }]}>
|
||||
{formatDate(document.created_at)}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={[styles.versionPreview, { color: colors.text }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{document.content.substring(0, 150)}
|
||||
{document.content.length > 150 ? '...' : ''}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Löschen-Button außerhalb der Touchable-Fläche für den Artikel */}
|
||||
{canDelete && onDeleteVersion && (
|
||||
<TouchableOpacity
|
||||
style={[styles.deleteSeparateButton, { backgroundColor: colors.card }]}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
console.log("Löschen-Button separat wurde gedrückt für:", document.id);
|
||||
|
||||
// Direkter Aufruf für Testzwecke
|
||||
if (onDeleteVersion) {
|
||||
console.log("Rufe onDeleteVersion direkt auf für Dokument ID:", document.id);
|
||||
onDeleteVersion(document);
|
||||
|
||||
// Schließe das Modal nach einer kurzen Verzögerung
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 100);
|
||||
} else {
|
||||
console.error("onDeleteVersion ist nicht definiert!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash" size={18} color="red" />
|
||||
<Text style={styles.deleteButtonText}>Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const renderVersionItem = (document: Document, isLatest: boolean) => {
|
||||
// Löschen nur anzeigen, wenn es mehr als eine Version gibt und es nicht die neueste ist
|
||||
// oder wenn es die einzige Version ist (nur zur Konsistenz)
|
||||
const canDelete = documents.length > 1 || !isLatest;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isVisible}
|
||||
animationType="slide"
|
||||
transparent={false}
|
||||
>
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Dokumentversionen</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.versionsList}>
|
||||
{documents.map((document, index) => renderVersionItem(document, index === 0))}
|
||||
|
||||
{documents.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="document-outline" size={48} color={colors.text + '40'} />
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Dokumentversionen verfügbar
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<View key={document.id} style={[styles.versionItem, { borderBottomColor: colors.border }]}>
|
||||
<TouchableOpacity
|
||||
style={{ flex: 1 }}
|
||||
activeOpacity={0.6}
|
||||
onPress={() => {
|
||||
console.log('Version auswählen:', document.id);
|
||||
onSelectVersion(document);
|
||||
}}
|
||||
>
|
||||
<View style={styles.versionHeader}>
|
||||
<View style={styles.versionBadge}>
|
||||
<Text style={styles.versionNumber}>v{document.version}</Text>
|
||||
</View>
|
||||
{isLatest && (
|
||||
<View style={[styles.latestBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.latestText}>Aktuell</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.versionDate, { color: colors.text + '99' }]}>
|
||||
{formatDate(document.created_at)}
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.versionPreview, { color: colors.text }]} numberOfLines={2}>
|
||||
{document.content.substring(0, 150)}
|
||||
{document.content.length > 150 ? '...' : ''}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Löschen-Button außerhalb der Touchable-Fläche für den Artikel */}
|
||||
{canDelete && onDeleteVersion && (
|
||||
<TouchableOpacity
|
||||
style={[styles.deleteSeparateButton, { backgroundColor: colors.card }]}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
console.log('Löschen-Button separat wurde gedrückt für:', document.id);
|
||||
|
||||
// Direkter Aufruf für Testzwecke
|
||||
if (onDeleteVersion) {
|
||||
console.log('Rufe onDeleteVersion direkt auf für Dokument ID:', document.id);
|
||||
onDeleteVersion(document);
|
||||
|
||||
// Schließe das Modal nach einer kurzen Verzögerung
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 100);
|
||||
} else {
|
||||
console.error('onDeleteVersion ist nicht definiert!');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash" size={18} color="red" />
|
||||
<Text style={styles.deleteButtonText}>Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={isVisible} animationType="slide" transparent={false}>
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Dokumentversionen</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.versionsList}>
|
||||
{documents.map((document, index) => renderVersionItem(document, index === 0))}
|
||||
|
||||
{documents.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="document-outline" size={48} color={colors.text + '40'} />
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Dokumentversionen verfügbar
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
versionsList: {
|
||||
flex: 1,
|
||||
},
|
||||
versionItem: {
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
versionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
versionBadge: {
|
||||
backgroundColor: '#e0e0e0',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
versionNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
latestBadge: {
|
||||
marginLeft: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
latestText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
},
|
||||
deleteSeparateButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 8,
|
||||
marginHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ff3b30',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: 'red',
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
versionDate: {
|
||||
fontSize: 13,
|
||||
marginBottom: 8,
|
||||
},
|
||||
versionPreview: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
versionsList: {
|
||||
flex: 1,
|
||||
},
|
||||
versionItem: {
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
versionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
versionBadge: {
|
||||
backgroundColor: '#e0e0e0',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
versionNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
latestBadge: {
|
||||
marginLeft: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
latestText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
},
|
||||
deleteSeparateButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 8,
|
||||
marginHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ff3b30',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: 'red',
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
versionDate: {
|
||||
fontSize: 13,
|
||||
marginBottom: 8,
|
||||
},
|
||||
versionPreview: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,105 +4,106 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { useTheme } from '@react-navigation/native';
|
||||
|
||||
type MessageInputProps = {
|
||||
onSend: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
onSend: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
// Öffentliche Methoden über Ref
|
||||
export interface MessageInputRef {
|
||||
focus: () => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
function MessageInput({ onSend, isLoading = false }, ref) {
|
||||
const [message, setMessage] = useState('');
|
||||
const { colors } = useTheme();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Stellt die focus-Methode über ref zur Verfügung
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}));
|
||||
const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
|
||||
{ onSend, isLoading = false },
|
||||
ref
|
||||
) {
|
||||
const [message, setMessage] = useState('');
|
||||
const { colors } = useTheme();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
const handleSend = () => {
|
||||
if (message.trim() && !isLoading) {
|
||||
onSend(message.trim());
|
||||
setMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
// Tastatur-Event-Handler für Enter-Taste (besonders wichtig für Web)
|
||||
const handleKeyPress = (e: any) => {
|
||||
// Prüfen auf Enter ohne Shift für Submit
|
||||
if (e.nativeEvent.key === 'Enter' && !e.nativeEvent.shiftKey) {
|
||||
e.preventDefault(); // Verhindert Zeilenumbruch
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
// Stellt die focus-Methode über ref zur Verfügung
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, { color: colors.text, backgroundColor: colors.background }]}
|
||||
placeholder="Nachricht eingeben..."
|
||||
placeholderTextColor={colors.text + '80'}
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
editable={!isLoading}
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
onKeyPress={handleKeyPress}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleSend}
|
||||
disabled={!message.trim() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Ionicons name="send" size={20} color="#fff" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
const handleSend = () => {
|
||||
if (message.trim() && !isLoading) {
|
||||
onSend(message.trim());
|
||||
setMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(0,0,0,0.1)',
|
||||
width: '100%',
|
||||
maxWidth: 1200,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
maxHeight: 120,
|
||||
marginRight: 8,
|
||||
},
|
||||
sendButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
// Tastatur-Event-Handler für Enter-Taste (besonders wichtig für Web)
|
||||
const handleKeyPress = (e: any) => {
|
||||
// Prüfen auf Enter ohne Shift für Submit
|
||||
if (e.nativeEvent.key === 'Enter' && !e.nativeEvent.shiftKey) {
|
||||
e.preventDefault(); // Verhindert Zeilenumbruch
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, { color: colors.text, backgroundColor: colors.background }]}
|
||||
placeholder="Nachricht eingeben..."
|
||||
placeholderTextColor={colors.text + '80'}
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
editable={!isLoading}
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
onKeyPress={handleKeyPress}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleSend}
|
||||
disabled={!message.trim() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Ionicons name="send" size={20} color="#fff" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default MessageInput;
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(0,0,0,0.1)',
|
||||
width: '100%',
|
||||
maxWidth: 1200,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
maxHeight: 120,
|
||||
marginRight: 8,
|
||||
},
|
||||
sendButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default MessageInput;
|
||||
|
|
|
|||
|
|
@ -5,93 +5,79 @@ import SkeletonLoader from './SkeletonLoader';
|
|||
import TypingIndicator from './TypingIndicator';
|
||||
|
||||
type MessageProps = {
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
isLoading?: boolean;
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function MessageItem({
|
||||
text,
|
||||
sender,
|
||||
timestamp,
|
||||
isLoading = false
|
||||
}: MessageProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const isUser = sender === 'user';
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
isUser ? styles.userContainer : styles.aiContainer,
|
||||
{ backgroundColor: isUser ? colors.primary : colors.card }
|
||||
]}>
|
||||
{isLoading && sender === 'ai' ? (
|
||||
// Zeige Skeleton oder TypingIndicator wenn geladen wird
|
||||
<>
|
||||
<SkeletonLoader
|
||||
lines={4}
|
||||
style={styles.skeletonContainer}
|
||||
/>
|
||||
<TypingIndicator
|
||||
dotColor={colors.text + '80'}
|
||||
style={styles.typingIndicator}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Zeige die eigentliche Nachricht
|
||||
<Text style={[
|
||||
styles.messageText,
|
||||
{ color: isUser ? '#fff' : colors.text }
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text style={[
|
||||
styles.timestamp,
|
||||
{ color: isUser ? 'rgba(255,255,255,0.7)' : colors.text + '80' }
|
||||
]}>
|
||||
{timestamp.getHours().toString().padStart(2, '0')}:{timestamp.getMinutes().toString().padStart(2, '0')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
export default function MessageItem({ text, sender, timestamp, isLoading = false }: MessageProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const isUser = sender === 'user';
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
isUser ? styles.userContainer : styles.aiContainer,
|
||||
{ backgroundColor: isUser ? colors.primary : colors.card },
|
||||
]}
|
||||
>
|
||||
{isLoading && sender === 'ai' ? (
|
||||
// Zeige Skeleton oder TypingIndicator wenn geladen wird
|
||||
<>
|
||||
<SkeletonLoader lines={4} style={styles.skeletonContainer} />
|
||||
<TypingIndicator dotColor={colors.text + '80'} style={styles.typingIndicator} />
|
||||
</>
|
||||
) : (
|
||||
// Zeige die eigentliche Nachricht
|
||||
<Text style={[styles.messageText, { color: isUser ? '#fff' : colors.text }]}>{text}</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[styles.timestamp, { color: isUser ? 'rgba(255,255,255,0.7)' : colors.text + '80' }]}
|
||||
>
|
||||
{timestamp.getHours().toString().padStart(2, '0')}:
|
||||
{timestamp.getMinutes().toString().padStart(2, '0')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 12,
|
||||
borderRadius: 16,
|
||||
marginVertical: 4,
|
||||
marginHorizontal: 12,
|
||||
},
|
||||
userContainer: {
|
||||
maxWidth: '80%',
|
||||
alignSelf: 'flex-start',
|
||||
borderBottomLeftRadius: 4,
|
||||
},
|
||||
aiContainer: {
|
||||
width: '95%',
|
||||
alignSelf: 'flex-end',
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
skeletonContainer: {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
opacity: 0.8,
|
||||
},
|
||||
typingIndicator: {
|
||||
marginLeft: -5,
|
||||
marginTop: 5,
|
||||
}
|
||||
container: {
|
||||
padding: 12,
|
||||
borderRadius: 16,
|
||||
marginVertical: 4,
|
||||
marginHorizontal: 12,
|
||||
},
|
||||
userContainer: {
|
||||
maxWidth: '80%',
|
||||
alignSelf: 'flex-start',
|
||||
borderBottomLeftRadius: 4,
|
||||
},
|
||||
aiContainer: {
|
||||
width: '95%',
|
||||
alignSelf: 'flex-end',
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
skeletonContainer: {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
opacity: 0.8,
|
||||
},
|
||||
typingIndicator: {
|
||||
marginLeft: -5,
|
||||
marginTop: 5,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,62 +3,62 @@ import { FlatList, StyleSheet, View } from 'react-native';
|
|||
import MessageItem from './MessageItem';
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
isLoading?: boolean;
|
||||
id: string;
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
type MessageListProps = {
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function MessageList({ messages, isLoading = false }: MessageListProps) {
|
||||
const renderMessageItem = ({ item, index }: { item: Message, index: number }) => {
|
||||
// Wenn die Nachricht die letzte ist und vom KI-Assistenten stammt,
|
||||
// zeigen wir den Lade-Indikator an, wenn isLoading=true ist
|
||||
const isLastMessage = index === messages.length - 1;
|
||||
const isLastAIMessage = isLastMessage && item.sender === 'ai';
|
||||
const shouldShowLoading = isLoading && isLastAIMessage;
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
text={item.text}
|
||||
sender={item.sender}
|
||||
timestamp={item.timestamp}
|
||||
isLoading={shouldShowLoading || item.isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const renderMessageItem = ({ item, index }: { item: Message; index: number }) => {
|
||||
// Wenn die Nachricht die letzte ist und vom KI-Assistenten stammt,
|
||||
// zeigen wir den Lade-Indikator an, wenn isLoading=true ist
|
||||
const isLastMessage = index === messages.length - 1;
|
||||
const isLastAIMessage = isLastMessage && item.sender === 'ai';
|
||||
const shouldShowLoading = isLoading && isLastAIMessage;
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={messages}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderMessageItem}
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
inverted={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListFooterComponent={<View style={styles.footer} />}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<MessageItem
|
||||
text={item.text}
|
||||
sender={item.sender}
|
||||
timestamp={item.timestamp}
|
||||
isLoading={shouldShowLoading || item.isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={messages}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderMessageItem}
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
inverted={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListFooterComponent={<View style={styles.footer} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
maxWidth: 800,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
contentContainer: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
footer: {
|
||||
height: 20,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
maxWidth: 800,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
contentContainer: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
footer: {
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,118 +5,108 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { Model } from '../types';
|
||||
|
||||
type ModelCardProps = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
deployment?: string;
|
||||
isSelected?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
model?: Model; // Optionales komplettes Model-Objekt
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
deployment?: string;
|
||||
isSelected?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
model?: Model; // Optionales komplettes Model-Objekt
|
||||
};
|
||||
|
||||
export default function ModelCard({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
model
|
||||
export default function ModelCard({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
model,
|
||||
}: ModelCardProps) {
|
||||
const { colors } = useTheme();
|
||||
const deployment = model?.parameters?.deployment;
|
||||
const { colors } = useTheme();
|
||||
const deployment = model?.parameters?.deployment;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: isSelected ? colors.primary : 'transparent',
|
||||
}
|
||||
]}
|
||||
onPress={() => onSelect(id)}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons
|
||||
name="chatbubble-ellipses-outline"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={[styles.name, { color: colors.text }]}>{name}</Text>
|
||||
<Text
|
||||
style={[styles.description, { color: colors.text + '80' }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{deployment && (
|
||||
<Text
|
||||
style={[styles.deployment, { color: colors.primary + 'CC' }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{deployment}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isSelected && (
|
||||
<View style={[styles.checkmark, { backgroundColor: colors.primary }]}>
|
||||
<Ionicons name="checkmark" size={16} color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: isSelected ? colors.primary : 'transparent',
|
||||
},
|
||||
]}
|
||||
onPress={() => onSelect(id)}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="chatbubble-ellipses-outline" size={24} color={colors.primary} />
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={[styles.name, { color: colors.text }]}>{name}</Text>
|
||||
<Text style={[styles.description, { color: colors.text + '80' }]} numberOfLines={2}>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{deployment && (
|
||||
<Text style={[styles.deployment, { color: colors.primary + 'CC' }]} numberOfLines={1}>
|
||||
{deployment}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isSelected && (
|
||||
<View style={[styles.checkmark, { backgroundColor: colors.primary }]}>
|
||||
<Ionicons name="checkmark" size={16} color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
borderWidth: 2,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 4,
|
||||
},
|
||||
deployment: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
checkmark: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
borderWidth: 2,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 4,
|
||||
},
|
||||
deployment: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
checkmark: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,152 +10,152 @@ import { getModels } from '../services/modelService';
|
|||
const FALLBACK_MODELS: Model[] = availableModels;
|
||||
|
||||
type ModelDropdownProps = {
|
||||
selectedModelId: string;
|
||||
onSelectModel: (id: string) => void;
|
||||
selectedModelId: string;
|
||||
onSelectModel: (id: string) => void;
|
||||
};
|
||||
|
||||
export default function ModelDropdown({ selectedModelId, onSelectModel }: ModelDropdownProps) {
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [models, setModels] = useState<Model[]>(FALLBACK_MODELS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [models, setModels] = useState<Model[]>(FALLBACK_MODELS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Lade die Modelle vom ModelService
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const modelsList = await getModels();
|
||||
setModels(modelsList);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Modelle:', err);
|
||||
setModels(FALLBACK_MODELS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchModels();
|
||||
}, []);
|
||||
// Lade die Modelle vom ModelService
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const modelsList = await getModels();
|
||||
setModels(modelsList);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Modelle:', err);
|
||||
setModels(FALLBACK_MODELS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedModel = models.find(model => model.id === selectedModelId) || models[0];
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
className={`flex-row items-center rounded-lg px-2 py-1 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-gray-100'}`}
|
||||
>
|
||||
<Text className={`text-sm font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
{selectedModel.name}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color={isDarkMode ? '#FFFFFF' : '#000000'}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
const selectedModel = models.find((model) => model.id === selectedModelId) || models[0];
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View
|
||||
className={`mx-4 rounded-xl p-4 ${isDarkMode ? 'bg-[#1C1C1E]' : 'bg-white'}`}
|
||||
style={styles.modalContent}
|
||||
>
|
||||
<Text className={`text-lg font-bold mb-4 ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
Modell auswählen
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<View className="py-4 items-center">
|
||||
<Text className={`${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Modelle werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={models}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center p-3 mb-2 rounded-lg ${
|
||||
item.id === selectedModelId
|
||||
? isDarkMode ? 'bg-blue-900/30' : 'bg-blue-100'
|
||||
: isDarkMode ? 'bg-[#2C2C2E]' : 'bg-gray-100'
|
||||
}`}
|
||||
onPress={() => {
|
||||
onSelectModel(item.id);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<View className="w-8 h-8 rounded-full bg-blue-500/20 items-center justify-center mr-3">
|
||||
<Ionicons
|
||||
name="chatbubble-ellipses-outline"
|
||||
size={16}
|
||||
color="#0A84FF"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className={`font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-xs mt-1 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
{item.parameters?.deployment && (
|
||||
<Text
|
||||
className={`text-xs mt-1 ${isDarkMode ? 'text-blue-400' : 'text-blue-500'}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.parameters.deployment}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{item.id === selectedModelId && (
|
||||
<View className="w-6 h-6 rounded-full bg-blue-500 items-center justify-center">
|
||||
<Ionicons name="checkmark" size={14} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
className={`mt-3 py-3 rounded-lg items-center ${isDarkMode ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]'}`}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-white font-medium">Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
className={`flex-row items-center rounded-lg px-2 py-1 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-gray-100'}`}
|
||||
>
|
||||
<Text className={`text-sm font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
{selectedModel.name}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color={isDarkMode ? '#FFFFFF' : '#000000'}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View
|
||||
className={`mx-4 rounded-xl p-4 ${isDarkMode ? 'bg-[#1C1C1E]' : 'bg-white'}`}
|
||||
style={styles.modalContent}
|
||||
>
|
||||
<Text className={`mb-4 text-lg font-bold ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
Modell auswählen
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<View className="items-center py-4">
|
||||
<Text className={`${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Modelle werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={models}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
className={`mb-2 flex-row items-center rounded-lg p-3 ${
|
||||
item.id === selectedModelId
|
||||
? isDarkMode
|
||||
? 'bg-blue-900/30'
|
||||
: 'bg-blue-100'
|
||||
: isDarkMode
|
||||
? 'bg-[#2C2C2E]'
|
||||
: 'bg-gray-100'
|
||||
}`}
|
||||
onPress={() => {
|
||||
onSelectModel(item.id);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<View className="mr-3 h-8 w-8 items-center justify-center rounded-full bg-blue-500/20">
|
||||
<Ionicons name="chatbubble-ellipses-outline" size={16} color="#0A84FF" />
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className={`font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text
|
||||
className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
{item.parameters?.deployment && (
|
||||
<Text
|
||||
className={`mt-1 text-xs ${isDarkMode ? 'text-blue-400' : 'text-blue-500'}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.parameters.deployment}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{item.id === selectedModelId && (
|
||||
<View className="h-6 w-6 items-center justify-center rounded-full bg-blue-500">
|
||||
<Ionicons name="checkmark" size={14} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
className={`mt-3 items-center rounded-lg py-3 ${isDarkMode ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]'}`}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="font-medium text-white">Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
maxHeight: '80%',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
maxHeight: '80%',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,43 +4,43 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { useTheme } from '@react-navigation/native';
|
||||
|
||||
type NewChatButtonProps = {
|
||||
onPress: () => void;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
export default function NewChatButton({ onPress }: NewChatButtonProps) {
|
||||
const { colors } = useTheme();
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Ionicons name="add" size={24} color="#fff" style={styles.icon} />
|
||||
<Text style={styles.text}>Neuer Chat</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Ionicons name="add" size={24} color="#fff" style={styles.icon} />
|
||||
<Text style={styles.text}>Neuer Chat</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 30,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
icon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
text: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 30,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
icon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
text: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,79 +3,75 @@ import { View, Animated, Easing, StyleSheet } from 'react-native';
|
|||
import { useTheme } from '@react-navigation/native';
|
||||
|
||||
type SkeletonLoaderProps = {
|
||||
lines?: number;
|
||||
animated?: boolean;
|
||||
style?: any;
|
||||
lines?: number;
|
||||
animated?: boolean;
|
||||
style?: any;
|
||||
};
|
||||
|
||||
export default function SkeletonLoader({
|
||||
lines = 3,
|
||||
animated = true,
|
||||
style
|
||||
}: SkeletonLoaderProps) {
|
||||
const { colors } = useTheme();
|
||||
const [fadeAnim] = useState(new Animated.Value(0.3));
|
||||
export default function SkeletonLoader({ lines = 3, animated = true, style }: SkeletonLoaderProps) {
|
||||
const { colors } = useTheme();
|
||||
const [fadeAnim] = useState(new Animated.Value(0.3));
|
||||
|
||||
useEffect(() => {
|
||||
if (animated) {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.8,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.3,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
}
|
||||
}, [fadeAnim, animated]);
|
||||
useEffect(() => {
|
||||
if (animated) {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.8,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.3,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
}
|
||||
}, [fadeAnim, animated]);
|
||||
|
||||
// Erstelle verschiedene Längen für die Zeilen
|
||||
const getRandomWidth = (index: number) => {
|
||||
// Erste und letzte Zeile sind kürzer
|
||||
if (index === 0) return { width: '70%' };
|
||||
if (index === lines - 1) return { width: '40%' };
|
||||
|
||||
// Zufällige Breite für die Zeilen dazwischen
|
||||
const widths = ['85%', '90%', '75%', '95%'];
|
||||
return { width: widths[index % widths.length] };
|
||||
};
|
||||
// Erstelle verschiedene Längen für die Zeilen
|
||||
const getRandomWidth = (index: number) => {
|
||||
// Erste und letzte Zeile sind kürzer
|
||||
if (index === 0) return { width: '70%' };
|
||||
if (index === lines - 1) return { width: '40%' };
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.line,
|
||||
getRandomWidth(index),
|
||||
{
|
||||
backgroundColor: colors.text + '20',
|
||||
opacity: fadeAnim,
|
||||
marginBottom: index === lines - 1 ? 0 : 8
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
// Zufällige Breite für die Zeilen dazwischen
|
||||
const widths = ['85%', '90%', '75%', '95%'];
|
||||
return { width: widths[index % widths.length] };
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.line,
|
||||
getRandomWidth(index),
|
||||
{
|
||||
backgroundColor: colors.text + '20',
|
||||
opacity: fadeAnim,
|
||||
marginBottom: index === lines - 1 ? 0 : 8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
maxWidth: '80%',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
line: {
|
||||
height: 15,
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
padding: 16,
|
||||
maxWidth: '80%',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
line: {
|
||||
height: 15,
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,175 +6,155 @@ import { useAppTheme } from '../theme/ThemeProvider';
|
|||
|
||||
// Typ für die Template-Props
|
||||
interface TemplateCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
systemPrompt: string;
|
||||
color?: string;
|
||||
isDefault?: boolean;
|
||||
onPress: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onSetDefault?: (id: string) => void;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
systemPrompt: string;
|
||||
color?: string;
|
||||
isDefault?: boolean;
|
||||
onPress: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onSetDefault?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function TemplateCard({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
systemPrompt,
|
||||
color = '#0A84FF',
|
||||
isDefault = false,
|
||||
onPress,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSetDefault
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
systemPrompt,
|
||||
color = '#0A84FF',
|
||||
isDefault = false,
|
||||
onPress,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSetDefault,
|
||||
}: TemplateCardProps) {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
const backgroundColor = isDarkMode ? '#2C2C2E' : '#FFFFFF';
|
||||
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
|
||||
const secondaryTextColor = isDarkMode ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)';
|
||||
|
||||
// Kürze den System-Prompt für die Anzeige
|
||||
const truncatedPrompt = systemPrompt.length > 80
|
||||
? systemPrompt.substring(0, 80) + '...'
|
||||
: systemPrompt;
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor },
|
||||
isDefault && styles.defaultContainer
|
||||
]}
|
||||
onPress={() => onPress(id)}
|
||||
>
|
||||
{/* Farbiger Indikator am linken Rand */}
|
||||
<View style={[styles.colorIndicator, { backgroundColor: color }]} />
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.name, { color: textColor }]}>{name}</Text>
|
||||
|
||||
{isDefault && (
|
||||
<View style={styles.defaultBadge}>
|
||||
<Text style={styles.defaultText}>Standard</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{description && (
|
||||
<Text
|
||||
style={[styles.description, { color: secondaryTextColor }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[styles.prompt, { color: secondaryTextColor }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{truncatedPrompt}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Aktionen */}
|
||||
<View style={styles.actions}>
|
||||
{onSetDefault && !isDefault && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onSetDefault(id)}
|
||||
>
|
||||
<Ionicons name="star-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onEdit && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onEdit(id)}
|
||||
>
|
||||
<Ionicons name="pencil" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onDelete(id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
const backgroundColor = isDarkMode ? '#2C2C2E' : '#FFFFFF';
|
||||
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
|
||||
const secondaryTextColor = isDarkMode ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)';
|
||||
|
||||
// Kürze den System-Prompt für die Anzeige
|
||||
const truncatedPrompt =
|
||||
systemPrompt.length > 80 ? systemPrompt.substring(0, 80) + '...' : systemPrompt;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { backgroundColor }, isDefault && styles.defaultContainer]}
|
||||
onPress={() => onPress(id)}
|
||||
>
|
||||
{/* Farbiger Indikator am linken Rand */}
|
||||
<View style={[styles.colorIndicator, { backgroundColor: color }]} />
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.name, { color: textColor }]}>{name}</Text>
|
||||
|
||||
{isDefault && (
|
||||
<View style={styles.defaultBadge}>
|
||||
<Text style={styles.defaultText}>Standard</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{description && (
|
||||
<Text style={[styles.description, { color: secondaryTextColor }]} numberOfLines={2}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text style={[styles.prompt, { color: secondaryTextColor }]} numberOfLines={2}>
|
||||
{truncatedPrompt}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Aktionen */}
|
||||
<View style={styles.actions}>
|
||||
{onSetDefault && !isDefault && (
|
||||
<TouchableOpacity style={styles.actionButton} onPress={() => onSetDefault(id)}>
|
||||
<Ionicons name="star-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onEdit && (
|
||||
<TouchableOpacity style={styles.actionButton} onPress={() => onEdit(id)}>
|
||||
<Ionicons name="pencil" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<TouchableOpacity style={styles.actionButton} onPress={() => onDelete(id)}>
|
||||
<Ionicons name="trash-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
defaultContainer: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#0A84FF',
|
||||
},
|
||||
colorIndicator: {
|
||||
width: 8,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
defaultBadge: {
|
||||
backgroundColor: '#0A84FF',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 8,
|
||||
},
|
||||
defaultText: {
|
||||
color: 'white',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
prompt: {
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
actions: {
|
||||
padding: 8,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
padding: 8,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
defaultContainer: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#0A84FF',
|
||||
},
|
||||
colorIndicator: {
|
||||
width: 8,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
defaultBadge: {
|
||||
backgroundColor: '#0A84FF',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 8,
|
||||
},
|
||||
defaultText: {
|
||||
color: 'white',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
prompt: {
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
actions: {
|
||||
padding: 8,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
padding: 8,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
|
|
@ -18,400 +18,401 @@ import { Template } from '../services/template';
|
|||
|
||||
// Verfügbare Farben für Vorlagen
|
||||
const TEMPLATE_COLORS = [
|
||||
'#0A84FF', // Blau
|
||||
'#32D74B', // Grün
|
||||
'#FF375F', // Rot
|
||||
'#FF9F0A', // Orange
|
||||
'#5E5CE6', // Lila
|
||||
'#BF5AF2', // Pink
|
||||
'#64D2FF', // Hellblau
|
||||
'#30D158', // Grün
|
||||
'#FF453A', // Rot
|
||||
'#0A84FF', // Blau
|
||||
'#32D74B', // Grün
|
||||
'#FF375F', // Rot
|
||||
'#FF9F0A', // Orange
|
||||
'#5E5CE6', // Lila
|
||||
'#BF5AF2', // Pink
|
||||
'#64D2FF', // Hellblau
|
||||
'#30D158', // Grün
|
||||
'#FF453A', // Rot
|
||||
];
|
||||
|
||||
interface TemplateFormProps {
|
||||
initialData?: Partial<Template>;
|
||||
onSubmit: (data: Partial<Template>) => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Partial<Template>;
|
||||
onSubmit: (data: Partial<Template>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function TemplateForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel
|
||||
}: TemplateFormProps) {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState(initialData?.name || '');
|
||||
const [description, setDescription] = useState(initialData?.description || '');
|
||||
const [systemPrompt, setSystemPrompt] = useState(initialData?.system_prompt || '');
|
||||
const [initialQuestion, setInitialQuestion] = useState(initialData?.initial_question || '');
|
||||
const [selectedColor, setSelectedColor] = useState(initialData?.color || TEMPLATE_COLORS[0]);
|
||||
const [selectedModelId, setSelectedModelId] = useState(initialData?.model_id || '');
|
||||
const [documentMode, setDocumentMode] = useState(initialData?.document_mode || false);
|
||||
|
||||
// Validierung
|
||||
const [errors, setErrors] = useState<{
|
||||
name?: string;
|
||||
systemPrompt?: string;
|
||||
}>({});
|
||||
|
||||
// Helpers
|
||||
const isEditMode = !!initialData?.id;
|
||||
const bgColor = isDarkMode ? '#1C1C1E' : '#FFFFFF';
|
||||
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
|
||||
const placeholderColor = isDarkMode ? '#8E8E93' : '#C7C7CC';
|
||||
const borderColor = isDarkMode ? '#38383A' : '#E5E5EA';
|
||||
|
||||
// Validiere das Formular vor dem Absenden
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: {
|
||||
name?: string;
|
||||
systemPrompt?: string;
|
||||
} = {};
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Bitte gib einen Namen ein.';
|
||||
}
|
||||
|
||||
if (!systemPrompt.trim()) {
|
||||
newErrors.systemPrompt = 'Der System-Prompt darf nicht leer sein.';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
onSubmit({
|
||||
id: initialData?.id,
|
||||
name,
|
||||
description: description.trim() || null,
|
||||
system_prompt: systemPrompt,
|
||||
initial_question: initialQuestion.trim() || null,
|
||||
color: selectedColor,
|
||||
model_id: selectedModelId || null,
|
||||
document_mode: documentMode
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={[styles.container, { backgroundColor: bgColor }]}
|
||||
>
|
||||
<ScrollView style={styles.scrollView}>
|
||||
<View style={styles.form}>
|
||||
{/* Titel */}
|
||||
<Text style={[styles.title, { color: textColor }]}>
|
||||
{isEditMode ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}
|
||||
</Text>
|
||||
|
||||
{/* Name */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Name *</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: errors.name ? '#FF3B30' : borderColor
|
||||
}
|
||||
]}
|
||||
placeholder="Name der Vorlage"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
maxLength={50}
|
||||
/>
|
||||
{errors.name && (
|
||||
<Text style={styles.errorText}>{errors.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Beschreibung (optional)</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor
|
||||
}
|
||||
]}
|
||||
placeholder="Kurze Beschreibung dieser Vorlage"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
maxLength={200}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* System-Prompt */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>System-Prompt *</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: errors.systemPrompt ? '#FF3B30' : borderColor,
|
||||
height: 150
|
||||
}
|
||||
]}
|
||||
placeholder="System-Prompt für die KI"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={systemPrompt}
|
||||
onChangeText={setSystemPrompt}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{errors.systemPrompt && (
|
||||
<Text style={styles.errorText}>{errors.systemPrompt}</Text>
|
||||
)}
|
||||
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Der System-Prompt definiert die Rolle und das Verhalten der KI.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Initiale Frage */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Beispielfrage (optional)</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: borderColor,
|
||||
height: 80
|
||||
}
|
||||
]}
|
||||
placeholder="Beispiel für eine passende Frage oder Anweisung"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={initialQuestion}
|
||||
onChangeText={setInitialQuestion}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Diese Frage wird als Vorschlag angezeigt, wenn die Vorlage ausgewählt wird.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Farbe auswählen */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Farbe</Text>
|
||||
<View style={styles.colorPicker}>
|
||||
{TEMPLATE_COLORS.map((color) => (
|
||||
<TouchableOpacity
|
||||
key={color}
|
||||
style={[
|
||||
styles.colorOption,
|
||||
{ backgroundColor: color },
|
||||
selectedColor === color && styles.selectedColorOption
|
||||
]}
|
||||
onPress={() => setSelectedColor(color)}
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<Ionicons name="checkmark" size={16} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Modell auswählen */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Bevorzugtes Modell (optional)</Text>
|
||||
<ModelDropdown
|
||||
selectedModelId={selectedModelId}
|
||||
onSelectModel={setSelectedModelId}
|
||||
/>
|
||||
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Falls ausgewählt, wird dieses Modell automatisch mit der Vorlage verwendet.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Dokumentmodus */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Dokumentmodus</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.switchContainer,
|
||||
{
|
||||
backgroundColor: documentMode ? colors.primary + '20' : isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: documentMode ? colors.primary : borderColor
|
||||
}
|
||||
]}
|
||||
onPress={() => setDocumentMode(!documentMode)}
|
||||
>
|
||||
<View style={styles.switchText}>
|
||||
<Text style={[styles.switchLabel, { color: textColor }]}>
|
||||
Dokumentmodus aktivieren
|
||||
</Text>
|
||||
<Text style={[styles.switchDescription, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Ermöglicht die Bearbeitung eines Dokuments während der Konversation
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[
|
||||
styles.switchButton,
|
||||
{ backgroundColor: documentMode ? colors.primary : isDarkMode ? '#636366' : '#C7C7CC' }
|
||||
]}>
|
||||
{documentMode ? (
|
||||
<Ionicons name="checkmark" size={14} color="white" />
|
||||
) : (
|
||||
<Ionicons name="close" size={14} color="white" />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton, { borderColor }]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: textColor }]}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.submitButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: 'white' }]}>
|
||||
{isEditMode ? 'Speichern' : 'Erstellen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
export default function TemplateForm({ initialData, onSubmit, onCancel }: TemplateFormProps) {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState(initialData?.name || '');
|
||||
const [description, setDescription] = useState(initialData?.description || '');
|
||||
const [systemPrompt, setSystemPrompt] = useState(initialData?.system_prompt || '');
|
||||
const [initialQuestion, setInitialQuestion] = useState(initialData?.initial_question || '');
|
||||
const [selectedColor, setSelectedColor] = useState(initialData?.color || TEMPLATE_COLORS[0]);
|
||||
const [selectedModelId, setSelectedModelId] = useState(initialData?.model_id || '');
|
||||
const [documentMode, setDocumentMode] = useState(initialData?.document_mode || false);
|
||||
|
||||
// Validierung
|
||||
const [errors, setErrors] = useState<{
|
||||
name?: string;
|
||||
systemPrompt?: string;
|
||||
}>({});
|
||||
|
||||
// Helpers
|
||||
const isEditMode = !!initialData?.id;
|
||||
const bgColor = isDarkMode ? '#1C1C1E' : '#FFFFFF';
|
||||
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
|
||||
const placeholderColor = isDarkMode ? '#8E8E93' : '#C7C7CC';
|
||||
const borderColor = isDarkMode ? '#38383A' : '#E5E5EA';
|
||||
|
||||
// Validiere das Formular vor dem Absenden
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: {
|
||||
name?: string;
|
||||
systemPrompt?: string;
|
||||
} = {};
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Bitte gib einen Namen ein.';
|
||||
}
|
||||
|
||||
if (!systemPrompt.trim()) {
|
||||
newErrors.systemPrompt = 'Der System-Prompt darf nicht leer sein.';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
onSubmit({
|
||||
id: initialData?.id,
|
||||
name,
|
||||
description: description.trim() || null,
|
||||
system_prompt: systemPrompt,
|
||||
initial_question: initialQuestion.trim() || null,
|
||||
color: selectedColor,
|
||||
model_id: selectedModelId || null,
|
||||
document_mode: documentMode,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={[styles.container, { backgroundColor: bgColor }]}
|
||||
>
|
||||
<ScrollView style={styles.scrollView}>
|
||||
<View style={styles.form}>
|
||||
{/* Titel */}
|
||||
<Text style={[styles.title, { color: textColor }]}>
|
||||
{isEditMode ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}
|
||||
</Text>
|
||||
|
||||
{/* Name */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Name *</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: errors.name ? '#FF3B30' : borderColor,
|
||||
},
|
||||
]}
|
||||
placeholder="Name der Vorlage"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
maxLength={50}
|
||||
/>
|
||||
{errors.name && <Text style={styles.errorText}>{errors.name}</Text>}
|
||||
</View>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Beschreibung (optional)</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor,
|
||||
},
|
||||
]}
|
||||
placeholder="Kurze Beschreibung dieser Vorlage"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
maxLength={200}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* System-Prompt */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>System-Prompt *</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: errors.systemPrompt ? '#FF3B30' : borderColor,
|
||||
height: 150,
|
||||
},
|
||||
]}
|
||||
placeholder="System-Prompt für die KI"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={systemPrompt}
|
||||
onChangeText={setSystemPrompt}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{errors.systemPrompt && <Text style={styles.errorText}>{errors.systemPrompt}</Text>}
|
||||
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Der System-Prompt definiert die Rolle und das Verhalten der KI.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Initiale Frage */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Beispielfrage (optional)</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: borderColor,
|
||||
height: 80,
|
||||
},
|
||||
]}
|
||||
placeholder="Beispiel für eine passende Frage oder Anweisung"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={initialQuestion}
|
||||
onChangeText={setInitialQuestion}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Diese Frage wird als Vorschlag angezeigt, wenn die Vorlage ausgewählt wird.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Farbe auswählen */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Farbe</Text>
|
||||
<View style={styles.colorPicker}>
|
||||
{TEMPLATE_COLORS.map((color) => (
|
||||
<TouchableOpacity
|
||||
key={color}
|
||||
style={[
|
||||
styles.colorOption,
|
||||
{ backgroundColor: color },
|
||||
selectedColor === color && styles.selectedColorOption,
|
||||
]}
|
||||
onPress={() => setSelectedColor(color)}
|
||||
>
|
||||
{selectedColor === color && <Ionicons name="checkmark" size={16} color="white" />}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Modell auswählen */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Bevorzugtes Modell (optional)</Text>
|
||||
<ModelDropdown selectedModelId={selectedModelId} onSelectModel={setSelectedModelId} />
|
||||
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Falls ausgewählt, wird dieses Modell automatisch mit der Vorlage verwendet.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Dokumentmodus */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Dokumentmodus</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.switchContainer,
|
||||
{
|
||||
backgroundColor: documentMode
|
||||
? colors.primary + '20'
|
||||
: isDarkMode
|
||||
? '#2C2C2E'
|
||||
: '#F2F2F7',
|
||||
borderColor: documentMode ? colors.primary : borderColor,
|
||||
},
|
||||
]}
|
||||
onPress={() => setDocumentMode(!documentMode)}
|
||||
>
|
||||
<View style={styles.switchText}>
|
||||
<Text style={[styles.switchLabel, { color: textColor }]}>
|
||||
Dokumentmodus aktivieren
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.switchDescription, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}
|
||||
>
|
||||
Ermöglicht die Bearbeitung eines Dokuments während der Konversation
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.switchButton,
|
||||
{
|
||||
backgroundColor: documentMode
|
||||
? colors.primary
|
||||
: isDarkMode
|
||||
? '#636366'
|
||||
: '#C7C7CC',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{documentMode ? (
|
||||
<Ionicons name="checkmark" size={14} color="white" />
|
||||
) : (
|
||||
<Ionicons name="close" size={14} color="white" />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton, { borderColor }]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: textColor }]}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.submitButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: 'white' }]}>
|
||||
{isEditMode ? 'Speichern' : 'Erstellen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
form: {
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
},
|
||||
formGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
marginTop: 6,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: '#FF3B30',
|
||||
marginTop: 6,
|
||||
},
|
||||
colorPicker: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 10,
|
||||
},
|
||||
colorOption: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
margin: 5,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
selectedColorOption: {
|
||||
borderWidth: 2,
|
||||
borderColor: 'white',
|
||||
},
|
||||
switchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
},
|
||||
switchText: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
switchDescription: {
|
||||
fontSize: 12,
|
||||
},
|
||||
switchButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 30,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
minWidth: 120,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#0A84FF',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
form: {
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
},
|
||||
formGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
marginTop: 6,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: '#FF3B30',
|
||||
marginTop: 6,
|
||||
},
|
||||
colorPicker: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 10,
|
||||
},
|
||||
colorOption: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
margin: 5,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
selectedColorOption: {
|
||||
borderWidth: 2,
|
||||
borderColor: 'white',
|
||||
},
|
||||
switchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
},
|
||||
switchText: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
switchDescription: {
|
||||
fontSize: 12,
|
||||
},
|
||||
switchButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 30,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
minWidth: 120,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#0A84FF',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,101 +3,101 @@ import { View, Text, StyleSheet, Animated, Easing } from 'react-native';
|
|||
import { useTheme } from '@react-navigation/native';
|
||||
|
||||
type TypingIndicatorProps = {
|
||||
dotCount?: number;
|
||||
dotSize?: number;
|
||||
dotColor?: string;
|
||||
style?: any;
|
||||
dotCount?: number;
|
||||
dotSize?: number;
|
||||
dotColor?: string;
|
||||
style?: any;
|
||||
};
|
||||
|
||||
export default function TypingIndicator({
|
||||
dotCount = 3,
|
||||
dotSize = 8,
|
||||
dotColor,
|
||||
style,
|
||||
dotCount = 3,
|
||||
dotSize = 8,
|
||||
dotColor,
|
||||
style,
|
||||
}: TypingIndicatorProps) {
|
||||
const { colors } = useTheme();
|
||||
const [animations] = useState(() =>
|
||||
Array.from({ length: dotCount }).map(() => new Animated.Value(0))
|
||||
);
|
||||
const { colors } = useTheme();
|
||||
const [animations] = useState(() =>
|
||||
Array.from({ length: dotCount }).map(() => new Animated.Value(0))
|
||||
);
|
||||
|
||||
// Dotfarbe wird entweder von Prop oder vom Theme übernommen
|
||||
const actualDotColor = dotColor || colors.text;
|
||||
// Dotfarbe wird entweder von Prop oder vom Theme übernommen
|
||||
const actualDotColor = dotColor || colors.text;
|
||||
|
||||
useEffect(() => {
|
||||
// Animiere jeden Punkt mit einer Verzögerung
|
||||
const animateDots = () => {
|
||||
const animationSequence = animations.map((anim, i) =>
|
||||
Animated.sequence([
|
||||
// Verzögerung für jeden Punkt
|
||||
Animated.delay(i * 150),
|
||||
// Animation nach oben
|
||||
Animated.timing(anim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
// Animation zurück nach unten
|
||||
Animated.timing(anim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
// Verzögerung am Ende
|
||||
Animated.delay((dotCount - i - 1) * 150),
|
||||
])
|
||||
);
|
||||
useEffect(() => {
|
||||
// Animiere jeden Punkt mit einer Verzögerung
|
||||
const animateDots = () => {
|
||||
const animationSequence = animations.map((anim, i) =>
|
||||
Animated.sequence([
|
||||
// Verzögerung für jeden Punkt
|
||||
Animated.delay(i * 150),
|
||||
// Animation nach oben
|
||||
Animated.timing(anim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
// Animation zurück nach unten
|
||||
Animated.timing(anim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
// Verzögerung am Ende
|
||||
Animated.delay((dotCount - i - 1) * 150),
|
||||
])
|
||||
);
|
||||
|
||||
// Starte alle Animationen parallel und in einer Schleife
|
||||
Animated.loop(Animated.parallel(animationSequence)).start();
|
||||
};
|
||||
// Starte alle Animationen parallel und in einer Schleife
|
||||
Animated.loop(Animated.parallel(animationSequence)).start();
|
||||
};
|
||||
|
||||
animateDots();
|
||||
animateDots();
|
||||
|
||||
// Cleanup beim Unmount
|
||||
return () => {
|
||||
animations.forEach(anim => anim.stopAnimation());
|
||||
};
|
||||
}, [animations, dotCount]);
|
||||
// Cleanup beim Unmount
|
||||
return () => {
|
||||
animations.forEach((anim) => anim.stopAnimation());
|
||||
};
|
||||
}, [animations, dotCount]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{animations.map((anim, index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.dot,
|
||||
{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
backgroundColor: actualDotColor,
|
||||
borderRadius: dotSize / 2,
|
||||
marginHorizontal: dotSize / 3,
|
||||
transform: [
|
||||
{
|
||||
translateY: anim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, -dotSize],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{animations.map((anim, index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.dot,
|
||||
{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
backgroundColor: actualDotColor,
|
||||
borderRadius: dotSize / 2,
|
||||
marginHorizontal: dotSize / 3,
|
||||
transform: [
|
||||
{
|
||||
translateY: anim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, -dotSize],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 10,
|
||||
},
|
||||
dot: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 10,
|
||||
},
|
||||
dot: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,49 +7,49 @@
|
|||
// Available models for the chat application
|
||||
// These match the models configured in the backend
|
||||
export const availableModels = [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'GPT-O3-Mini',
|
||||
description: 'Azure OpenAI O3-Mini: Effizientes Modell für schnelle Antworten.',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 800,
|
||||
provider: 'azure',
|
||||
deployment: 'gpt-o3-mini-se',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440004',
|
||||
name: 'GPT-4o-Mini',
|
||||
description: 'Azure OpenAI GPT-4o-Mini: Kompaktes, leistungsstarkes KI-Modell.',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
provider: 'azure',
|
||||
deployment: 'gpt-4o-mini-se',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440005',
|
||||
name: 'GPT-4o',
|
||||
description: 'Azure OpenAI GPT-4o: Das fortschrittlichste multimodale KI-Modell.',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1200,
|
||||
provider: 'azure',
|
||||
deployment: 'gpt-4o-se',
|
||||
}
|
||||
}
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'GPT-O3-Mini',
|
||||
description: 'Azure OpenAI O3-Mini: Effizientes Modell für schnelle Antworten.',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 800,
|
||||
provider: 'azure',
|
||||
deployment: 'gpt-o3-mini-se',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440004',
|
||||
name: 'GPT-4o-Mini',
|
||||
description: 'Azure OpenAI GPT-4o-Mini: Kompaktes, leistungsstarkes KI-Modell.',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
provider: 'azure',
|
||||
deployment: 'gpt-4o-mini-se',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440005',
|
||||
name: 'GPT-4o',
|
||||
description: 'Azure OpenAI GPT-4o: Das fortschrittlichste multimodale KI-Modell.',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1200,
|
||||
provider: 'azure',
|
||||
deployment: 'gpt-4o-se',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to get model by ID
|
||||
export function getModelById(modelId: string) {
|
||||
return availableModels.find(m => m.id === modelId);
|
||||
return availableModels.find((m) => m.id === modelId);
|
||||
}
|
||||
|
||||
// Helper function to get model by deployment name
|
||||
export function getModelByDeployment(deployment: string) {
|
||||
return availableModels.find(m => m.parameters.deployment === deployment);
|
||||
return availableModels.find((m) => m.parameters.deployment === deployment);
|
||||
}
|
||||
|
||||
// Default model
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
|
|||
import { ActivityIndicator, View, Text } from 'react-native';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import {
|
||||
createAuthService,
|
||||
createTokenManager,
|
||||
setStorageAdapter,
|
||||
setDeviceAdapter,
|
||||
setNetworkAdapter,
|
||||
createMemoryStorageAdapter,
|
||||
type UserData,
|
||||
createAuthService,
|
||||
createTokenManager,
|
||||
setStorageAdapter,
|
||||
setDeviceAdapter,
|
||||
setNetworkAdapter,
|
||||
createMemoryStorageAdapter,
|
||||
type UserData,
|
||||
} from '@manacore/shared-auth';
|
||||
|
||||
// Mana Core Auth URL from environment
|
||||
|
|
@ -16,59 +16,59 @@ const MANA_AUTH_URL = process.env.EXPO_PUBLIC_MANA_CORE_AUTH_URL || 'http://loca
|
|||
|
||||
// Create SecureStore adapter for React Native
|
||||
const createSecureStoreAdapter = () => ({
|
||||
async getItem<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setItem(key: string, value: unknown): Promise<void> {
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(value));
|
||||
},
|
||||
async removeItem(key: string): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
},
|
||||
async getItem<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const value = await SecureStore.getItemAsync(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setItem(key: string, value: unknown): Promise<void> {
|
||||
await SecureStore.setItemAsync(key, JSON.stringify(value));
|
||||
},
|
||||
async removeItem(key: string): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
},
|
||||
});
|
||||
|
||||
// Create device adapter for React Native
|
||||
const createReactNativeDeviceAdapter = () => {
|
||||
let deviceId: string | null = null;
|
||||
let deviceId: string | null = null;
|
||||
|
||||
return {
|
||||
async getDeviceInfo() {
|
||||
if (!deviceId) {
|
||||
// Try to get stored device ID
|
||||
deviceId = await SecureStore.getItemAsync('@device/id');
|
||||
return {
|
||||
async getDeviceInfo() {
|
||||
if (!deviceId) {
|
||||
// Try to get stored device ID
|
||||
deviceId = await SecureStore.getItemAsync('@device/id');
|
||||
|
||||
if (!deviceId) {
|
||||
// Generate new device ID
|
||||
deviceId = `rn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
await SecureStore.setItemAsync('@device/id', deviceId);
|
||||
}
|
||||
}
|
||||
if (!deviceId) {
|
||||
// Generate new device ID
|
||||
deviceId = `rn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
await SecureStore.setItemAsync('@device/id', deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deviceId,
|
||||
deviceName: 'React Native Device',
|
||||
platform: 'react-native',
|
||||
};
|
||||
},
|
||||
async getStoredDeviceId() {
|
||||
return deviceId || (await SecureStore.getItemAsync('@device/id'));
|
||||
},
|
||||
};
|
||||
return {
|
||||
deviceId,
|
||||
deviceName: 'React Native Device',
|
||||
platform: 'react-native',
|
||||
};
|
||||
},
|
||||
async getStoredDeviceId() {
|
||||
return deviceId || (await SecureStore.getItemAsync('@device/id'));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Create network adapter (basic implementation)
|
||||
const createReactNativeNetworkAdapter = () => ({
|
||||
async isConnected() {
|
||||
return true; // Always assume connected for now
|
||||
},
|
||||
async hasStableConnection() {
|
||||
return true;
|
||||
},
|
||||
async isConnected() {
|
||||
return true; // Always assume connected for now
|
||||
},
|
||||
async hasStableConnection() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize adapters
|
||||
|
|
@ -82,12 +82,12 @@ const tokenManager = createTokenManager(authService);
|
|||
|
||||
// Auth context type
|
||||
type AuthContextType = {
|
||||
user: UserData | null;
|
||||
loading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<{ error: any | null }>;
|
||||
signUp: (email: string, password: string) => Promise<{ error: any | null; data: any | null }>;
|
||||
signOut: () => Promise<void>;
|
||||
resetPassword: (email: string) => Promise<{ error: any | null }>;
|
||||
user: UserData | null;
|
||||
loading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<{ error: any | null }>;
|
||||
signUp: (email: string, password: string) => Promise<{ error: any | null; data: any | null }>;
|
||||
signOut: () => Promise<void>;
|
||||
resetPassword: (email: string) => Promise<{ error: any | null }>;
|
||||
};
|
||||
|
||||
// Create auth context
|
||||
|
|
@ -95,137 +95,137 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||
|
||||
// Hook to access auth context
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// AuthProvider component
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<UserData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<UserData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Initialize auth state
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Initialize auth state
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Check if user is authenticated
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
// Check if user is authenticated
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
setUser(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Initialisieren der Auth-Session:', error);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
setUser(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Initialisieren der Auth-Session:', error);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
}, []);
|
||||
initialize();
|
||||
}, []);
|
||||
|
||||
// Sign in with email and password
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
console.log('Versuche Anmeldung mit:', email);
|
||||
const result = await authService.signIn(email, password);
|
||||
// Sign in with email and password
|
||||
const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
console.log('Versuche Anmeldung mit:', email);
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Auth Fehler:', result.error);
|
||||
return { error: { message: result.error } };
|
||||
}
|
||||
if (!result.success) {
|
||||
console.error('Auth Fehler:', result.error);
|
||||
return { error: { message: result.error } };
|
||||
}
|
||||
|
||||
// Get user data from token
|
||||
const userData = await authService.getUserFromToken();
|
||||
setUser(userData);
|
||||
// Get user data from token
|
||||
const userData = await authService.getUserFromToken();
|
||||
setUser(userData);
|
||||
|
||||
console.log('Anmeldung erfolgreich:', userData?.userId);
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
console.error('Unerwarteter Fehler beim Anmelden:', error.message || error);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
console.log('Anmeldung erfolgreich:', userData?.userId);
|
||||
return { error: null };
|
||||
} catch (error: any) {
|
||||
console.error('Unerwarteter Fehler beim Anmelden:', error.message || error);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
|
||||
// Sign up with email and password
|
||||
const signUp = async (email: string, password: string) => {
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Sign up with email and password
|
||||
const signUp = async (email: string, password: string) => {
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { data: null, error: { message: result.error } };
|
||||
}
|
||||
if (!result.success) {
|
||||
return { data: null, error: { message: result.error } };
|
||||
}
|
||||
|
||||
// Auto sign in after successful signup
|
||||
const signInResult = await signIn(email, password);
|
||||
// Auto sign in after successful signup
|
||||
const signInResult = await signIn(email, password);
|
||||
|
||||
if (signInResult.error) {
|
||||
return { data: null, error: signInResult.error };
|
||||
}
|
||||
if (signInResult.error) {
|
||||
return { data: null, error: signInResult.error };
|
||||
}
|
||||
|
||||
return { data: user, error: null };
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Registrieren:', error);
|
||||
return { data: null, error };
|
||||
}
|
||||
};
|
||||
return { data: user, error: null };
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Registrieren:', error);
|
||||
return { data: null, error };
|
||||
}
|
||||
};
|
||||
|
||||
// Sign out
|
||||
const signOut = async () => {
|
||||
try {
|
||||
await authService.signOut();
|
||||
setUser(null);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abmelden:', error);
|
||||
}
|
||||
};
|
||||
// Sign out
|
||||
const signOut = async () => {
|
||||
try {
|
||||
await authService.signOut();
|
||||
setUser(null);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abmelden:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset password
|
||||
const resetPassword = async (email: string) => {
|
||||
try {
|
||||
const result = await authService.forgotPassword(email);
|
||||
// Reset password
|
||||
const resetPassword = async (email: string) => {
|
||||
try {
|
||||
const result = await authService.forgotPassword(email);
|
||||
|
||||
if (!result.success) {
|
||||
return { error: { message: result.error } };
|
||||
}
|
||||
if (!result.success) {
|
||||
return { error: { message: result.error } };
|
||||
}
|
||||
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Zurücksetzen des Passworts:', error);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Zurücksetzen des Passworts:', error);
|
||||
return { error };
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading indicator during initialization
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color="#0A84FF" />
|
||||
<Text style={{ marginTop: 16 }}>Authentifizierung wird initialisiert...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Show loading indicator during initialization
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator size="large" color="#0A84FF" />
|
||||
<Text style={{ marginTop: 16 }}>Authentifizierung wird initialisiert...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Provide auth context
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
resetPassword,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
// Provide auth context
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
resetPassword,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 15.0.15",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
"cli": {
|
||||
"version": ">= 15.0.15",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,49 +2,49 @@ import { useState } from 'react';
|
|||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
|
||||
interface UseChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
initialText?: string;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
onSend: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
initialText?: string;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
interface UseChatInputReturn {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
handleSend: () => void;
|
||||
canSend: boolean;
|
||||
isLoading: boolean;
|
||||
isDarkMode: boolean;
|
||||
placeholder: string;
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
handleSend: () => void;
|
||||
canSend: boolean;
|
||||
isLoading: boolean;
|
||||
isDarkMode: boolean;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export default function useChatInput({
|
||||
onSend,
|
||||
isLoading = false,
|
||||
initialText = '',
|
||||
placeholder = 'Nachricht eingeben...',
|
||||
maxLength = 1000,
|
||||
onSend,
|
||||
isLoading = false,
|
||||
initialText = '',
|
||||
placeholder = 'Nachricht eingeben...',
|
||||
maxLength = 1000,
|
||||
}: UseChatInputProps): UseChatInputReturn {
|
||||
const [text, setText] = useState(initialText);
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
const canSend = text.trim().length > 0 && !isLoading;
|
||||
|
||||
const handleSend = () => {
|
||||
if (canSend) {
|
||||
onSend(text.trim());
|
||||
setText('');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
handleSend,
|
||||
canSend,
|
||||
isLoading,
|
||||
isDarkMode,
|
||||
placeholder,
|
||||
};
|
||||
}
|
||||
const [text, setText] = useState(initialText);
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
const canSend = text.trim().length > 0 && !isLoading;
|
||||
|
||||
const handleSend = () => {
|
||||
if (canSend) {
|
||||
onSend(text.trim());
|
||||
setText('');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
handleSend,
|
||||
canSend,
|
||||
isLoading,
|
||||
isDarkMode,
|
||||
placeholder,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue