mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 01:26:42 +02:00
feat(chat): integrate chat project into monorepo with full app structure
- Restructure chat as apps/mobile, apps/web, apps/landing, backend - Add NestJS backend for secure Azure OpenAI API calls - Remove exposed API key from mobile app (security fix) - Add shared chat-types package - Create SvelteKit web app scaffold - Create Astro landing page scaffold - Update pnpm workspace configuration - Add project-level CLAUDE.md documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
fcf3a344b1
commit
c638a7ffee
155 changed files with 22622 additions and 348 deletions
22
chat/apps/mobile/components/Button.tsx
Normal file
22
chat/apps/mobile/components/Button.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
|
||||
|
||||
type ButtonProps = {
|
||||
title: string;
|
||||
} & TouchableOpacityProps;
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
ref={ref}
|
||||
{...touchableProps}
|
||||
className={`${styles.button} ${touchableProps.className}`}>
|
||||
<Text className={styles.buttonText}>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = {
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
};
|
||||
93
chat/apps/mobile/components/ChatHeader.tsx
Normal file
93
chat/apps/mobile/components/ChatHeader.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
type ChatHeaderProps = {
|
||||
title?: string;
|
||||
modelName: string;
|
||||
conversationMode: string;
|
||||
onBackPress?: () => void;
|
||||
};
|
||||
|
||||
export default function ChatHeader({
|
||||
title,
|
||||
modelName,
|
||||
conversationMode,
|
||||
onBackPress
|
||||
}: ChatHeaderProps) {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
const handleBackPress = () => {
|
||||
if (onBackPress) {
|
||||
onBackPress();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>
|
||||
{title || 'Neuer Chat'}
|
||||
</Text>
|
||||
<View style={styles.subtitleContainer}>
|
||||
<Text style={[styles.modelName, { color: colors.text + '80' }]}>
|
||||
{modelName}
|
||||
</Text>
|
||||
<Text style={[styles.modeText, { color: colors.text + '80' }]}>
|
||||
{conversationMode === 'frei' ? 'Freier Modus' :
|
||||
conversationMode === 'geführt' ? 'Geführter Modus' : 'Vorlagen-Modus'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.menuButton}>
|
||||
<Ionicons name="ellipsis-vertical" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
width: '100%',
|
||||
maxWidth: 1200,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
backButton: {
|
||||
padding: 4,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
subtitleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
modeText: {
|
||||
fontSize: 13,
|
||||
marginLeft: 8,
|
||||
},
|
||||
menuButton: {
|
||||
padding: 4,
|
||||
},
|
||||
});
|
||||
122
chat/apps/mobile/components/ChatInput.tsx
Normal file
122
chat/apps/mobile/components/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import useChatInput from '../hooks/useChatInput';
|
||||
import ModelDropdown from './ModelDropdown';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
placeholder?: string;
|
||||
showModelSelection?: boolean;
|
||||
selectedModelId?: string;
|
||||
onSelectModel?: (id: string) => void;
|
||||
showAttachments?: boolean;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
onSend,
|
||||
isLoading = false,
|
||||
placeholder = 'Nachricht eingeben...',
|
||||
showModelSelection = false,
|
||||
selectedModelId = '550e8400-e29b-41d4-a716-446655440000',
|
||||
onSelectModel = () => {},
|
||||
showAttachments = false,
|
||||
showSearch = false,
|
||||
}: ChatInputProps) {
|
||||
const {
|
||||
text,
|
||||
setText,
|
||||
handleSend,
|
||||
canSend,
|
||||
isDarkMode,
|
||||
} = useChatInput({
|
||||
onSend,
|
||||
isLoading,
|
||||
placeholder,
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="w-full px-4">
|
||||
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
|
||||
{showModelSelection && (
|
||||
<View className="flex-row justify-between items-center mb-3">
|
||||
<Text className={`text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
Modell:
|
||||
</Text>
|
||||
<ModelDropdown
|
||||
selectedModelId={selectedModelId}
|
||||
onSelectModel={onSelectModel}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
className={`w-full min-h-[40px] text-base rounded-lg px-4 py-2 ${
|
||||
isDarkMode
|
||||
? 'text-white bg-[#1C1C1E]'
|
||||
: 'text-black bg-gray-100'
|
||||
}`}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<View className="flex-row justify-between items-center mt-4">
|
||||
{(showAttachments || showSearch) && (
|
||||
<View className="flex-row space-x-4">
|
||||
{showAttachments && (
|
||||
<TouchableOpacity className="flex-row items-center">
|
||||
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
|
||||
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{showSearch && (
|
||||
<TouchableOpacity className="flex-row items-center">
|
||||
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
|
||||
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-3 py-2 rounded-full ${
|
||||
canSend ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'
|
||||
}`}
|
||||
onPress={handleSend}
|
||||
disabled={!canSend}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-row items-center">
|
||||
<View className="h-4 w-4 mr-1">
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
</View>
|
||||
<Text className="text-white">Wird gesendet...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons
|
||||
name="send"
|
||||
size={18}
|
||||
color={canSend ? '#FFFFFF' : '#0A84FF'}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-1 ${canSend ? 'text-white' : 'text-[#0A84FF]'}`}
|
||||
>
|
||||
Senden
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
338
chat/apps/mobile/components/ChatPromptInput.tsx
Normal file
338
chat/apps/mobile/components/ChatPromptInput.tsx
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import React, { useState, forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, ScrollView, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import ModelDropdown from './ModelDropdown';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { createConversation, addMessage } from '../services/conversation';
|
||||
import { supabase } from '../utils/supabase';
|
||||
import { useAuth } from '../context/AuthProvider';
|
||||
import { Template, getTemplates } from '../services/template';
|
||||
|
||||
type ConversationStarterProps = {
|
||||
onSend?: (message: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
// Definiere die Ref-Methoden, die von außen aufgerufen werden können
|
||||
export interface ConversationStarterRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const ConversationStarter = forwardRef<ConversationStarterRef, ConversationStarterProps>(({ onSend, placeholder = 'Ask anything' }, ref) => {
|
||||
const [text, setText] = useState('');
|
||||
const [selectedModelId, setSelectedModelId] = useState('550e8400-e29b-41d4-a716-446655440000'); // Default to Azure OpenAI GPT-O3-Mini
|
||||
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Expose methods via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Laden der Vorlagen beim ersten Rendern
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const userTemplates = await getTemplates(user.id);
|
||||
setTemplates(userTemplates);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Vorlagen:', error);
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplates();
|
||||
}, [user]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (text.trim()) {
|
||||
console.log("handleSend wird aufgerufen mit Text:", text.trim());
|
||||
|
||||
// Prüfen ob onSend-Prop existiert, aber für jetzt ignorieren
|
||||
if (onSend && false) { // Deaktiviert: wir wollen immer unseren eigenen Code ausführen
|
||||
console.log("onSend-Prop gefunden, rufe diese auf");
|
||||
onSend(text.trim());
|
||||
setText('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Andernfalls starte eine neue Konversation
|
||||
try {
|
||||
setIsCreatingConversation(true);
|
||||
console.log("Starte Erstellung einer neuen Konversation...");
|
||||
|
||||
// Verwende den Benutzer aus dem Auth-Kontext
|
||||
if (!user) {
|
||||
console.error('Kein Benutzer angemeldet');
|
||||
router.replace('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Chat starten mit Modell-ID: ${selectedModelId}`);
|
||||
|
||||
const trimmedText = text.trim();
|
||||
|
||||
// WICHTIG: Setze Text zurück, bevor wir navigieren (UI-Block vermeiden)
|
||||
setText('');
|
||||
|
||||
const mode = selectedTemplate ? 'template' : 'free';
|
||||
const templateId = selectedTemplate?.id;
|
||||
const modelToUse = selectedTemplate?.model_id || selectedModelId;
|
||||
|
||||
// Versuche zwei verschiedene Methoden, damit eine davon funktioniert
|
||||
try {
|
||||
// 1. Methode: Mit Route-Parametern im Objekt
|
||||
console.log(`Methode 1: Mit Parametern im Objekt (${mode}, ${templateId || 'keine Vorlage'})`);
|
||||
router.push({
|
||||
pathname: '/conversation/new',
|
||||
params: {
|
||||
initialMessage: trimmedText,
|
||||
modelId: modelToUse,
|
||||
mode: mode,
|
||||
...(templateId && { templateId })
|
||||
}
|
||||
});
|
||||
} catch (routerError) {
|
||||
console.error("Fehler bei Methode 1:", routerError);
|
||||
|
||||
// 2. Methode: Mit Query-String
|
||||
console.log(`Methode 2: Mit Query-String`);
|
||||
let queryParams = `?initialMessage=${encodeURIComponent(
|
||||
trimmedText
|
||||
)}&modelId=${encodeURIComponent(
|
||||
modelToUse
|
||||
)}&mode=${encodeURIComponent(mode)}`;
|
||||
|
||||
if (templateId) {
|
||||
queryParams += `&templateId=${encodeURIComponent(templateId)}`;
|
||||
}
|
||||
|
||||
router.push(`/conversation/new${queryParams}`);
|
||||
}
|
||||
|
||||
// Zurücksetzen der ausgewählten Vorlage nach Navigation
|
||||
setSelectedTemplate(null);
|
||||
|
||||
console.log(`Navigation zur Konversation ausgeführt`);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Konversation:', error);
|
||||
alert(`Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
|
||||
} finally {
|
||||
setIsCreatingConversation(false);
|
||||
}
|
||||
} else {
|
||||
console.log("Text ist leer, keine Aktion");
|
||||
}
|
||||
};
|
||||
|
||||
// Handler für das Auswählen einer Vorlage
|
||||
const handleTemplateSelect = (template: Template) => {
|
||||
// Wenn die Vorlage bereits ausgewählt ist, deaktivieren wir sie
|
||||
if (selectedTemplate?.id === template.id) {
|
||||
setSelectedTemplate(null);
|
||||
// Zurücksetzen des Texts, wenn es die Vorschau war
|
||||
if (text.startsWith('Frage: ')) {
|
||||
setText('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sonst wählen wir die Vorlage aus
|
||||
setSelectedTemplate(template);
|
||||
setSelectedModelId(template.model_id || selectedModelId);
|
||||
|
||||
// Vorschau der initialen Frage im Eingabefeld anzeigen, wenn vorhanden
|
||||
if (text.trim() === '') {
|
||||
if (template.initial_question) {
|
||||
setText(`Frage: ${template.initial_question}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="w-full px-4 max-w-3xl self-center">
|
||||
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
|
||||
<View className="flex-row justify-between items-center mb-3">
|
||||
<Text className={`text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Modell:</Text>
|
||||
<ModelDropdown
|
||||
selectedModelId={selectedModelId}
|
||||
onSelectModel={setSelectedModelId}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className={`w-full min-h-[40px] text-base ${isDarkMode ? 'text-white' : 'text-black'}`}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
/>
|
||||
|
||||
<View className="flex-row justify-between items-center mt-4">
|
||||
<View className="flex-row space-x-4">
|
||||
<TouchableOpacity className="flex-row items-center">
|
||||
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
|
||||
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity className="flex-row items-center">
|
||||
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
|
||||
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-3 py-2 rounded-full ${text.trim() ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'}`}
|
||||
onPress={() => {
|
||||
console.log("Senden-Button gedrückt");
|
||||
handleSend();
|
||||
}}
|
||||
disabled={!text.trim() || isCreatingConversation}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isCreatingConversation ? (
|
||||
<View className="flex-row items-center">
|
||||
<View className="h-4 w-4 mr-1">
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
</View>
|
||||
<Text className="text-white">Wird erstellt...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="send" size={18} color={text.trim() ? '#FFFFFF' : '#0A84FF'} />
|
||||
<Text className={`ml-1 ${text.trim() ? 'text-white' : 'text-[#0A84FF]'}`}>Senden</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-4">
|
||||
<View>
|
||||
<Text className={`text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
Vorlagen:
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
className="flex-row"
|
||||
>
|
||||
{isLoadingTemplates ? (
|
||||
<View className={`flex-row items-center justify-center mr-2 px-3 py-1 rounded-full border ${
|
||||
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
|
||||
}`}>
|
||||
<ActivityIndicator size="small" color={isDarkMode ? '#FFFFFF' : '#0A84FF'} style={{marginRight: 6}} />
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
Laden...
|
||||
</Text>
|
||||
</View>
|
||||
) : templates.length > 0 ? (
|
||||
templates.map((template) => (
|
||||
<TouchableOpacity
|
||||
key={template.id}
|
||||
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
|
||||
selectedTemplate?.id === template.id
|
||||
? isDarkMode
|
||||
? 'bg-[#0A84FF]80 border-[#0A84FF]'
|
||||
: 'bg-[#0A84FF]40 border-[#0A84FF]'
|
||||
: isDarkMode
|
||||
? 'bg-[#2C2C2E] border-[#38383A]'
|
||||
: 'bg-white border-[#E5E5EA]'
|
||||
}`}
|
||||
onPress={() => handleTemplateSelect(template)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: template.color || '#0A84FF',
|
||||
marginRight: 6
|
||||
}}
|
||||
/>
|
||||
<Text className={`text-sm ${
|
||||
selectedTemplate?.id === template.id
|
||||
? isDarkMode ? 'text-white font-medium' : 'text-[#0A84FF] font-medium'
|
||||
: isDarkMode ? 'text-white' : 'text-black'
|
||||
}`}>
|
||||
{template.name}
|
||||
</Text>
|
||||
{selectedTemplate?.id === template.id && (
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={14}
|
||||
color={isDarkMode ? '#FFFFFF' : '#0A84FF'}
|
||||
style={{marginLeft: 4}}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
|
||||
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
|
||||
}`}
|
||||
onPress={() => router.push('/templates')}
|
||||
>
|
||||
<Ionicons
|
||||
name="add-circle-outline"
|
||||
size={16}
|
||||
color={isDarkMode ? '#FFFFFF' : '#000000'}
|
||||
style={styles.chipIcon}
|
||||
/>
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
Vorlage erstellen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-3 py-1 rounded-full border ${
|
||||
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
|
||||
}`}
|
||||
onPress={() => router.push('/templates')}
|
||||
>
|
||||
<Ionicons
|
||||
name="settings-outline"
|
||||
size={16}
|
||||
color={isDarkMode ? '#FFFFFF' : '#000000'}
|
||||
style={styles.chipIcon}
|
||||
/>
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
Verwalten
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
// Styles für Elemente, die nicht mit NativeWind gestylt werden können
|
||||
const styles = StyleSheet.create({
|
||||
chipIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export default ConversationStarter;
|
||||
9
chat/apps/mobile/components/Container.tsx
Normal file
9
chat/apps/mobile/components/Container.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { SafeAreaView } from 'react-native';
|
||||
|
||||
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 m-6',
|
||||
};
|
||||
442
chat/apps/mobile/components/ConversationStarter.tsx
Normal file
442
chat/apps/mobile/components/ConversationStarter.tsx
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
import React, { useState, forwardRef, useImperativeHandle, useRef, useEffect } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, ScrollView, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import ModelDropdown from './ModelDropdown';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { createConversation, addMessage } from '../services/conversation';
|
||||
import { supabase } from '../utils/supabase';
|
||||
import { useAuth } from '../context/AuthProvider';
|
||||
import { Template, getTemplates } from '../services/template';
|
||||
import { Space, getUserSpaces } from '../services/space';
|
||||
|
||||
type ConversationStarterProps = {
|
||||
onSend?: (message: string) => void;
|
||||
placeholder?: string;
|
||||
spaceId?: string | null;
|
||||
};
|
||||
|
||||
// Definiere die Ref-Methoden, die von außen aufgerufen werden können
|
||||
export interface ConversationStarterRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const ConversationStarter = forwardRef<ConversationStarterRef, ConversationStarterProps>(({ onSend, placeholder = 'Was möchtest du wissen?', spaceId }, ref) => {
|
||||
const [text, setText] = useState('');
|
||||
const [selectedModelId, setSelectedModelId] = useState('550e8400-e29b-41d4-a716-446655440000'); // Default to Azure OpenAI GPT-O3-Mini
|
||||
const [isCreatingConversation, setIsCreatingConversation] = useState(false);
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [documentMode, setDocumentMode] = useState(false);
|
||||
const [currentSpace, setCurrentSpace] = useState<Space | null>(null);
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Expose methods via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Laden der Vorlagen und des aktuellen Space beim ersten Rendern
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const userTemplates = await getTemplates(user.id);
|
||||
setTemplates(userTemplates);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Vorlagen:', error);
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplates();
|
||||
}, [user]);
|
||||
|
||||
// Laden des Space-Namens, wenn eine spaceId vorhanden ist
|
||||
useEffect(() => {
|
||||
const loadSpace = async () => {
|
||||
if (!spaceId) {
|
||||
setCurrentSpace(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const space = await getSpace(spaceId);
|
||||
setCurrentSpace(space);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Space:', error);
|
||||
setCurrentSpace(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadSpace();
|
||||
}, [spaceId]);
|
||||
|
||||
// Tastatur-Event-Handler für Enter-Taste (besonders wichtig für Web)
|
||||
const handleKeyPress = (e: any) => {
|
||||
// Prüfen auf Enter ohne Shift für Submit
|
||||
if (e.nativeEvent.key === 'Enter' && !e.nativeEvent.shiftKey) {
|
||||
e.preventDefault(); // Verhindert Zeilenumbruch
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (text.trim()) {
|
||||
console.log("handleSend wird aufgerufen mit Text:", text.trim());
|
||||
|
||||
// Prüfen ob onSend-Prop existiert, aber für jetzt ignorieren
|
||||
if (onSend && false) { // Deaktiviert: wir wollen immer unseren eigenen Code ausführen
|
||||
console.log("onSend-Prop gefunden, rufe diese auf");
|
||||
onSend(text.trim());
|
||||
setText('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Andernfalls starte eine neue Konversation
|
||||
try {
|
||||
setIsCreatingConversation(true);
|
||||
console.log("Starte Erstellung einer neuen Konversation...");
|
||||
|
||||
// Verwende den Benutzer aus dem Auth-Kontext
|
||||
if (!user) {
|
||||
console.error('Kein Benutzer angemeldet');
|
||||
router.replace('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Chat starten mit Modell-ID: ${selectedModelId}`);
|
||||
|
||||
const trimmedText = text.trim();
|
||||
|
||||
// WICHTIG: Setze Text zurück, bevor wir navigieren (UI-Block vermeiden)
|
||||
setText('');
|
||||
|
||||
const mode = selectedTemplate ? 'template' : 'free';
|
||||
const templateId = selectedTemplate?.id;
|
||||
const modelToUse = selectedTemplate?.model_id || selectedModelId;
|
||||
|
||||
// Versuche zwei verschiedene Methoden, damit eine davon funktioniert
|
||||
try {
|
||||
// 1. Methode: Mit Route-Parametern im Objekt
|
||||
console.log(`Methode 1: Mit Parametern im Objekt (${mode}, ${templateId || 'keine Vorlage'}, documentMode: ${documentMode}, spaceId: ${spaceId || 'keiner'})`);
|
||||
router.push({
|
||||
pathname: '/conversation/new',
|
||||
params: {
|
||||
initialMessage: trimmedText,
|
||||
modelId: modelToUse,
|
||||
mode: mode,
|
||||
documentMode: documentMode ? 'true' : 'false',
|
||||
...(templateId && { templateId }),
|
||||
...(spaceId && { spaceId })
|
||||
}
|
||||
});
|
||||
} catch (routerError) {
|
||||
console.error("Fehler bei Methode 1:", routerError);
|
||||
|
||||
// 2. Methode: Mit Query-String
|
||||
console.log(`Methode 2: Mit Query-String`);
|
||||
let queryParams = `?initialMessage=${encodeURIComponent(
|
||||
trimmedText
|
||||
)}&modelId=${encodeURIComponent(
|
||||
modelToUse
|
||||
)}&mode=${encodeURIComponent(mode)}&documentMode=${encodeURIComponent(documentMode ? 'true' : 'false')}`;
|
||||
|
||||
if (templateId) {
|
||||
queryParams += `&templateId=${encodeURIComponent(templateId)}`;
|
||||
}
|
||||
|
||||
if (spaceId) {
|
||||
queryParams += `&spaceId=${encodeURIComponent(spaceId)}`;
|
||||
}
|
||||
|
||||
router.push(`/conversation/new${queryParams}`);
|
||||
}
|
||||
|
||||
// Zurücksetzen der ausgewählten Vorlage nach Navigation
|
||||
setSelectedTemplate(null);
|
||||
|
||||
console.log(`Navigation zur Konversation ausgeführt`);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Konversation:', error);
|
||||
alert(`Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`);
|
||||
} finally {
|
||||
setIsCreatingConversation(false);
|
||||
}
|
||||
} else {
|
||||
console.log("Text ist leer, keine Aktion");
|
||||
}
|
||||
};
|
||||
|
||||
// Handler für das Auswählen einer Vorlage
|
||||
const handleTemplateSelect = (template: Template) => {
|
||||
// Wenn die Vorlage bereits ausgewählt ist, deaktivieren wir sie
|
||||
if (selectedTemplate?.id === template.id) {
|
||||
setSelectedTemplate(null);
|
||||
// Auch den Dokumentmodus zurücksetzen
|
||||
setDocumentMode(false);
|
||||
} else {
|
||||
// Sonst wählen wir die Vorlage aus
|
||||
setSelectedTemplate(template);
|
||||
|
||||
// Modell automatisch auswählen, wenn die Vorlage eines definiert
|
||||
if (template.model_id) {
|
||||
setSelectedModelId(template.model_id);
|
||||
}
|
||||
|
||||
// Dokumentmodus automatisch übernehmen, wenn die Vorlage ihn aktiviert hat
|
||||
setDocumentMode(template.document_mode || false);
|
||||
console.log(`Template ${template.name} ausgewählt, Dokumentmodus: ${template.document_mode}`);
|
||||
}
|
||||
|
||||
// Nach der Auswahl/Abwahl einer Vorlage das Eingabefeld fokussieren
|
||||
// Kurze Verzögerung, um UI-Updates abzuschließen
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="w-full px-4 max-w-3xl self-center">
|
||||
{/* Container für den Titel mit fester Höhe - verhindert Layout-Verschiebung */}
|
||||
<View className="h-7 flex-row items-center">
|
||||
{selectedTemplate && (
|
||||
<Text
|
||||
className={`text-base font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedTemplate.name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{currentSpace && (
|
||||
<View className="flex-row items-center ml-auto">
|
||||
<Ionicons
|
||||
name="folder-open"
|
||||
size={16}
|
||||
color={colors.primary}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
<Text
|
||||
className={`text-sm font-medium`}
|
||||
style={{ color: colors.primary }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Space: {currentSpace.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
|
||||
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className={`w-full min-h-[40px] text-base ${isDarkMode ? 'text-white' : 'text-black'}`}
|
||||
placeholder={selectedTemplate?.initial_question ? selectedTemplate.initial_question : placeholder}
|
||||
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
onSubmitEditing={() => {
|
||||
if (text.trim()) {
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
blurOnSubmit={false}
|
||||
onKeyPress={handleKeyPress}
|
||||
/>
|
||||
|
||||
<View className="flex-row justify-between items-center mt-4">
|
||||
<View className="flex-row flex-wrap">
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center py-1 px-2 rounded-md mr-4 ${
|
||||
documentMode
|
||||
? 'bg-[#0A84FF]40 border border-[#0A84FF]'
|
||||
: isDarkMode
|
||||
? 'bg-[#2C2C2E] border border-[#38383A]'
|
||||
: 'bg-[#F2F2F7] border border-[#E5E5EA]'
|
||||
}`}
|
||||
onPress={() => setDocumentMode(!documentMode)}
|
||||
>
|
||||
<Ionicons
|
||||
name={documentMode ? "document" : "document-outline"}
|
||||
size={18}
|
||||
color={documentMode ? '#0A84FF' : (isDarkMode ? '#FFFFFF' : '#000000')}
|
||||
/>
|
||||
<Text className={`ml-1 ${documentMode ? 'text-[#0A84FF] font-medium' : (isDarkMode ? 'text-white' : 'text-black')}`}>
|
||||
Dokument
|
||||
</Text>
|
||||
{documentMode && (
|
||||
<Ionicons name="checkmark-circle" size={14} color="#0A84FF" style={{marginLeft: 4}} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity className="flex-row items-center mr-4">
|
||||
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
|
||||
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity className="flex-row items-center mr-4">
|
||||
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
|
||||
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="flex-row items-center">
|
||||
<ModelDropdown
|
||||
selectedModelId={selectedModelId}
|
||||
onSelectModel={setSelectedModelId}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-3 py-2 rounded-full ${text.trim() ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'}`}
|
||||
onPress={() => {
|
||||
console.log("Senden-Button gedrückt");
|
||||
handleSend();
|
||||
}}
|
||||
disabled={!text.trim() || isCreatingConversation}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isCreatingConversation ? (
|
||||
<View className="flex-row items-center">
|
||||
<View className="h-4 w-4 mr-1">
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
</View>
|
||||
<Text className="text-white">Wird erstellt...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="send" size={18} color={text.trim() ? '#FFFFFF' : '#0A84FF'} />
|
||||
<Text className={`ml-1 ${text.trim() ? 'text-white' : 'text-[#0A84FF]'}`}>Senden</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-4">
|
||||
<View>
|
||||
<Text className={`text-sm font-medium mb-1 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
Vorlagen:
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
className="flex-row"
|
||||
>
|
||||
{isLoadingTemplates ? (
|
||||
<View className={`flex-row items-center justify-center mr-2 px-3 py-1 rounded-full border ${
|
||||
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
|
||||
}`}>
|
||||
<ActivityIndicator size="small" color={isDarkMode ? '#FFFFFF' : '#0A84FF'} style={{marginRight: 6}} />
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
Laden...
|
||||
</Text>
|
||||
</View>
|
||||
) : templates.length > 0 ? (
|
||||
templates.map((template) => (
|
||||
<TouchableOpacity
|
||||
key={template.id}
|
||||
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
|
||||
selectedTemplate?.id === template.id
|
||||
? isDarkMode
|
||||
? 'bg-[#0A84FF]80 border-[#0A84FF]'
|
||||
: 'bg-[#0A84FF]40 border-[#0A84FF]'
|
||||
: isDarkMode
|
||||
? 'bg-[#2C2C2E] border-[#38383A]'
|
||||
: 'bg-white border-[#E5E5EA]'
|
||||
}`}
|
||||
onPress={() => handleTemplateSelect(template)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: template.color || '#0A84FF',
|
||||
marginRight: 6
|
||||
}}
|
||||
/>
|
||||
<Text className={`text-sm ${
|
||||
selectedTemplate?.id === template.id
|
||||
? isDarkMode ? 'text-white font-medium' : 'text-[#0A84FF] font-medium'
|
||||
: isDarkMode ? 'text-white' : 'text-black'
|
||||
}`}>
|
||||
{template.name}
|
||||
</Text>
|
||||
{selectedTemplate?.id === template.id && (
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={14}
|
||||
color={isDarkMode ? '#FFFFFF' : '#0A84FF'}
|
||||
style={{marginLeft: 4}}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center mr-2 px-3 py-1 rounded-full border ${
|
||||
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
|
||||
}`}
|
||||
onPress={() => router.push('/templates')}
|
||||
>
|
||||
<Ionicons
|
||||
name="add-circle-outline"
|
||||
size={16}
|
||||
color={isDarkMode ? '#FFFFFF' : '#000000'}
|
||||
style={styles.chipIcon}
|
||||
/>
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
Vorlage erstellen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-3 py-1 rounded-full border ${
|
||||
isDarkMode ? 'bg-[#2C2C2E] border-[#38383A]' : 'bg-white border-[#E5E5EA]'
|
||||
}`}
|
||||
onPress={() => router.push('/templates')}
|
||||
>
|
||||
<Ionicons
|
||||
name="settings-outline"
|
||||
size={16}
|
||||
color={isDarkMode ? '#FFFFFF' : '#000000'}
|
||||
style={styles.chipIcon}
|
||||
/>
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
Verwalten
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
// Styles für Elemente, die nicht mit NativeWind gestylt werden können
|
||||
const styles = StyleSheet.create({
|
||||
chipIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export default ConversationStarter;
|
||||
490
chat/apps/mobile/components/CustomDrawer.tsx
Normal file
490
chat/apps/mobile/components/CustomDrawer.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
StatusBar,
|
||||
ActivityIndicator,
|
||||
SafeAreaView,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import { useAuth } from '../context/AuthProvider';
|
||||
import { getConversations } from '../services/conversation';
|
||||
|
||||
const DRAWER_WIDTH = 260; // Breite des Drawer-Menüs
|
||||
|
||||
interface CustomDrawerProps {
|
||||
isVisible: boolean;
|
||||
focusInputOnHomeNavigate?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function CustomDrawer({
|
||||
isVisible,
|
||||
focusInputOnHomeNavigate,
|
||||
onClose
|
||||
}: CustomDrawerProps) {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { user, signOut } = useAuth();
|
||||
|
||||
const [recentChats, setRecentChats] = useState<{id: string, title: string}[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Lade die letzten Chats
|
||||
useEffect(() => {
|
||||
const loadRecentChats = async () => {
|
||||
if (!user || !isVisible) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const conversations = await getConversations(user.id);
|
||||
|
||||
// Nimm nur die letzten 10 Konversationen
|
||||
const recentOnes = conversations.slice(0, 10).map(conv => ({
|
||||
id: conv.id,
|
||||
title: conv.title || 'Unbenannte Konversation'
|
||||
}));
|
||||
|
||||
setRecentChats(recentOnes);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der letzten Chats:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isVisible) {
|
||||
loadRecentChats();
|
||||
}
|
||||
}, [user, isVisible]);
|
||||
|
||||
// Navigation zum Home-Screen (mit Input-Fokus)
|
||||
const navigateToHome = () => {
|
||||
router.push('/');
|
||||
if (focusInputOnHomeNavigate) {
|
||||
// Verzögerung, um sicherzustellen, dass der Bildschirm geladen ist
|
||||
setTimeout(() => {
|
||||
focusInputOnHomeNavigate();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigation zu einer Konversation
|
||||
const navigateToConversation = (id: string) => {
|
||||
router.push(`/conversation/${id}`);
|
||||
};
|
||||
|
||||
// Navigation zur Archiv-Seite
|
||||
const navigateToArchive = () => {
|
||||
router.push('/archive');
|
||||
};
|
||||
|
||||
// Navigation zur Vorlagen-Seite
|
||||
const navigateToTemplates = () => {
|
||||
router.push('/templates');
|
||||
};
|
||||
|
||||
// Navigation zur Dokumente-Seite
|
||||
const navigateToDocuments = () => {
|
||||
router.push('/documents');
|
||||
};
|
||||
|
||||
// Navigation zur Profilseite
|
||||
const navigateToProfile = () => {
|
||||
router.push('/profile');
|
||||
};
|
||||
|
||||
// Styling für das Drawer-Menü
|
||||
const bgColor = isDarkMode ? '#1C1C1E' : '#FFFFFF';
|
||||
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
|
||||
const separatorColor = isDarkMode ? '#38383A' : '#E5E5EA';
|
||||
const activeColor = '#0A84FF';
|
||||
|
||||
// Wenn der Drawer nicht sichtbar sein soll, gib nichts zurück
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
backgroundColor: bgColor,
|
||||
width: DRAWER_WIDTH,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: separatorColor
|
||||
}
|
||||
]}
|
||||
>
|
||||
{/* Drawer-Header */}
|
||||
<View style={styles.drawerHeader}>
|
||||
<Text style={[styles.drawerTitle, { color: textColor }]}>
|
||||
Menu
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.iconButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover }
|
||||
]}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Ionicons
|
||||
name="close"
|
||||
size={24}
|
||||
color={textColor}
|
||||
style={{ opacity: pressed ? 0.7 : 1 }}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Hauptaktionen */}
|
||||
<View style={styles.mainActions}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.mainActionButton,
|
||||
{ backgroundColor: activeColor },
|
||||
pressed && { opacity: 0.85 }
|
||||
]}
|
||||
onPress={navigateToHome}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={20} color="white" />
|
||||
<Text style={styles.mainActionText}>Neuen Chat starten</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.mainActionButton,
|
||||
{
|
||||
backgroundColor: hovered ? colors.buttonHover : 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
|
||||
marginTop: 8
|
||||
},
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={navigateToArchive}
|
||||
>
|
||||
<Ionicons name="archive-outline" size={20} color={textColor} />
|
||||
<Text style={[styles.mainActionText, { color: textColor }]}>Archiv ansehen</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.mainActionButton,
|
||||
{
|
||||
backgroundColor: hovered ? colors.buttonHover : 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
|
||||
marginTop: 8
|
||||
},
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => router.push('/conversations')}
|
||||
>
|
||||
<Ionicons name="chatbubbles-outline" size={20} color={textColor} />
|
||||
<Text style={[styles.mainActionText, { color: textColor }]}>Konversationen</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.mainActionButton,
|
||||
{
|
||||
backgroundColor: hovered ? colors.buttonHover : 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
|
||||
marginTop: 8
|
||||
},
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={navigateToDocuments}
|
||||
>
|
||||
<Ionicons name="document-text-outline" size={20} color={textColor} />
|
||||
<Text style={[styles.mainActionText, { color: textColor }]}>Dokumente ansehen</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.mainActionButton,
|
||||
{
|
||||
backgroundColor: hovered ? colors.buttonHover : 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
|
||||
marginTop: 8
|
||||
},
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={navigateToTemplates}
|
||||
>
|
||||
<Ionicons name="file-tray-full-outline" size={20} color={textColor} />
|
||||
<Text style={[styles.mainActionText, { color: textColor }]}>Vorlagen verwalten</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.mainActionButton,
|
||||
{
|
||||
backgroundColor: hovered ? colors.buttonHover : 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
|
||||
marginTop: 8
|
||||
},
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => router.push('/spaces')}
|
||||
>
|
||||
<Ionicons name="people-outline" size={20} color={textColor} />
|
||||
<Text style={[styles.mainActionText, { color: textColor }]}>Spaces</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.mainActionButton,
|
||||
{
|
||||
backgroundColor: hovered ? colors.buttonHover : 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: isDarkMode ? '#38383A' : '#D1D1D6',
|
||||
marginTop: 8
|
||||
},
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={navigateToProfile}
|
||||
>
|
||||
<Ionicons name="person-outline" size={20} color={textColor} />
|
||||
<Text style={[styles.mainActionText, { color: textColor }]}>Profil & Statistiken</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Trennlinie */}
|
||||
<View style={[styles.separator, { backgroundColor: separatorColor }]} />
|
||||
|
||||
{/* Letzte Chats */}
|
||||
<View style={styles.recentChatsHeader}>
|
||||
<Text style={[styles.recentChatsTitle, { color: textColor }]}>
|
||||
Letzte Chats
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Liste der letzten Chats */}
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={activeColor} />
|
||||
<Text style={[styles.loadingText, { color: textColor + '80' }]}>
|
||||
Chats werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.recentChatsList}>
|
||||
{recentChats.length > 0 ? (
|
||||
recentChats.map((chat) => (
|
||||
<Pressable
|
||||
key={chat.id}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.chatItem,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={() => navigateToConversation(chat.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons
|
||||
name="chatbubble-ellipses-outline"
|
||||
size={20}
|
||||
color={textColor + '99'}
|
||||
style={styles.chatIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.chatTitle,
|
||||
{ color: textColor }
|
||||
]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{chat.title}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyChatsContainer}>
|
||||
<Text style={[styles.emptyChatsText, { color: textColor + '80' }]}>
|
||||
Keine Chats vorhanden
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* Benutzerinformationen und Logout-Button */}
|
||||
<View style={styles.userSection}>
|
||||
<View style={styles.separator} />
|
||||
<View style={styles.userContainer}>
|
||||
{user && (
|
||||
<View style={styles.userInfo}>
|
||||
<Ionicons name="person-circle-outline" size={24} color={textColor} />
|
||||
<Text style={[styles.userEmail, { color: textColor }]}>
|
||||
{user.email}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.logoutButton,
|
||||
{ borderColor: separatorColor },
|
||||
hovered && { backgroundColor: colors.dangerHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => {
|
||||
signOut().then(() => router.replace('/auth/login'));
|
||||
}}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons name="log-out-outline" size={20} color={textColor} />
|
||||
<Text style={[styles.logoutText, { color: textColor }]}>
|
||||
Abmelden
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
drawer: {
|
||||
height: '100%',
|
||||
elevation: 5,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 2, height: 0 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
drawerHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
drawerTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
mainActions: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
mainActionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
mainActionText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginLeft: 8,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
marginVertical: 8,
|
||||
},
|
||||
recentChatsHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
recentChatsTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
recentChatsList: {
|
||||
flex: 1,
|
||||
},
|
||||
chatItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
chatIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
chatTitle: {
|
||||
fontSize: 15,
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
},
|
||||
emptyChatsContainer: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyChatsText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
userSection: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
marginTop: 'auto',
|
||||
},
|
||||
userContainer: {
|
||||
marginTop: 10,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
userEmail: {
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
},
|
||||
logoutButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginTop: 4,
|
||||
},
|
||||
logoutText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
385
chat/apps/mobile/components/DocumentPanel.tsx
Normal file
385
chat/apps/mobile/components/DocumentPanel.tsx
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Document } from '../services/document';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
interface DocumentPanelProps {
|
||||
document: Document | null;
|
||||
isLoading?: boolean;
|
||||
versionCount: number;
|
||||
onSave?: (content: string) => void;
|
||||
onShowVersions?: () => void;
|
||||
onNextVersion?: () => void;
|
||||
onPreviousVersion?: () => void;
|
||||
onDeleteVersion?: (document: Document) => void;
|
||||
}
|
||||
|
||||
// Hilfsfunktion, um zu prüfen, ob der Dark Mode aktiv ist
|
||||
const isDarkMode = (colors: any) => {
|
||||
return colors.background === '#000' ||
|
||||
colors.background === '#121212' ||
|
||||
colors.background.includes('rgba(0,0,0') ||
|
||||
colors.text === '#fff' ||
|
||||
colors.text === '#ffffff';
|
||||
};
|
||||
|
||||
export default function DocumentPanel({
|
||||
document,
|
||||
isLoading = false,
|
||||
versionCount,
|
||||
onSave,
|
||||
onShowVersions,
|
||||
onNextVersion,
|
||||
onPreviousVersion,
|
||||
onDeleteVersion
|
||||
}: DocumentPanelProps) {
|
||||
const { colors } = useTheme();
|
||||
const [content, setContent] = useState<string>(document?.content || '');
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
// Aktualisiere den Content, wenn sich das Dokument ändert
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
setContent(document.content);
|
||||
}
|
||||
}, [document]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setContent(document?.content || '');
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (onSave) {
|
||||
onSave(content);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const renderVersionControls = () => {
|
||||
// Aktuelle Version und Versionszählung
|
||||
const currentVersion = document?.version || 1;
|
||||
const hasMultipleVersions = versionCount > 1;
|
||||
const canGoBack = currentVersion > 1;
|
||||
const canGoForward = currentVersion < versionCount;
|
||||
|
||||
return (
|
||||
<View style={styles.versionControls}>
|
||||
{/* Pfeil zurück */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.versionArrow,
|
||||
!canGoBack && styles.versionArrowDisabled
|
||||
]}
|
||||
onPress={canGoBack ? onPreviousVersion : undefined}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={16}
|
||||
color={canGoBack ? '#666' : '#CCC'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Version Badge */}
|
||||
<TouchableOpacity
|
||||
style={styles.versionBadge}
|
||||
onPress={onShowVersions}
|
||||
>
|
||||
<Text style={styles.versionText}>v{currentVersion}</Text>
|
||||
{hasMultipleVersions && (
|
||||
<Text style={styles.versionCount}>{versionCount}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Pfeil vorwärts */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.versionArrow,
|
||||
!canGoForward && styles.versionArrowDisabled
|
||||
]}
|
||||
onPress={canGoForward ? onNextVersion : undefined}
|
||||
disabled={!canGoForward}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color={canGoForward ? '#666' : '#CCC'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text }]}>
|
||||
Dokument wird geladen...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Dokument</Text>
|
||||
{renderVersionControls()}
|
||||
<View style={styles.actions}>
|
||||
{editing ? (
|
||||
<>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleCancel}>
|
||||
<Ionicons name="close" size={22} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleSave}>
|
||||
<Ionicons name="checkmark" size={22} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{document && onDeleteVersion && versionCount > 1 && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => {
|
||||
if (document) {
|
||||
console.log('Löschen-Button in DocumentPanel gedrückt für Version:', document.version);
|
||||
|
||||
Alert.alert(
|
||||
"Version löschen",
|
||||
`Möchtest du die Version ${document.version} wirklich löschen?`,
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
style: "cancel"
|
||||
},
|
||||
{
|
||||
text: "Löschen",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
console.log('Löschvorgang bestätigt für Version:', document.version);
|
||||
if (onDeleteVersion) {
|
||||
onDeleteVersion(document);
|
||||
} else {
|
||||
console.error('onDeleteVersion Funktion ist nicht definiert');
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={22} color="#ff3b30" />
|
||||
<Text style={{fontSize: 10, color: '#ff3b30', marginLeft: 4}}>Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleEdit}>
|
||||
<Ionicons name="create-outline" size={22} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{editing ? (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.editor,
|
||||
{
|
||||
color: colors.text,
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.border
|
||||
}
|
||||
]}
|
||||
multiline
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
autoFocus
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
) : (
|
||||
<ScrollView style={styles.contentContainer}>
|
||||
{document?.content ? (
|
||||
<Markdown
|
||||
style={{
|
||||
body: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
lineHeight: 22
|
||||
},
|
||||
heading1: {
|
||||
color: colors.text,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
paddingBottom: 8,
|
||||
marginBottom: 12
|
||||
},
|
||||
heading2: {
|
||||
color: colors.text,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border + '60',
|
||||
paddingBottom: 6,
|
||||
marginBottom: 10
|
||||
},
|
||||
heading3: { color: colors.text },
|
||||
heading4: { color: colors.text },
|
||||
heading5: { color: colors.text },
|
||||
heading6: { color: colors.text },
|
||||
paragraph: {
|
||||
color: colors.text,
|
||||
marginBottom: 12
|
||||
},
|
||||
list_item: { color: colors.text },
|
||||
blockquote: {
|
||||
backgroundColor: colors.card,
|
||||
borderLeftColor: colors.primary,
|
||||
borderLeftWidth: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
marginVertical: 8
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: isDarkMode(colors) ? '#1E1E1E' : '#F5F5F5',
|
||||
padding: 10,
|
||||
borderRadius: 6
|
||||
},
|
||||
link: { color: colors.primary }
|
||||
}}
|
||||
>
|
||||
{document.content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<Text style={[styles.content, { color: colors.text }]}>
|
||||
Noch kein Dokument erstellt.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
versionControls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
versionArrow: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
versionArrowDisabled: {
|
||||
backgroundColor: 'rgba(0,0,0,0.02)',
|
||||
},
|
||||
versionBadge: {
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
versionText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#666',
|
||||
},
|
||||
versionCount: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
backgroundColor: '#666',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
marginLeft: 4,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
actionButton: {
|
||||
padding: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 16,
|
||||
flex: 1,
|
||||
paddingBottom: 60, // Extra padding für besseres Scrollen
|
||||
},
|
||||
content: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
editor: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
margin: 8,
|
||||
textAlignVertical: 'top',
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 10,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
243
chat/apps/mobile/components/DocumentVersions.tsx
Normal file
243
chat/apps/mobile/components/DocumentVersions.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Modal,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Document } from '../services/document';
|
||||
|
||||
interface DocumentVersionsProps {
|
||||
isVisible: boolean;
|
||||
documents: Document[];
|
||||
onClose: () => void;
|
||||
onSelectVersion: (document: Document) => void;
|
||||
onDeleteVersion?: (document: Document) => void;
|
||||
}
|
||||
|
||||
export default function DocumentVersions({
|
||||
isVisible,
|
||||
documents,
|
||||
onClose,
|
||||
onSelectVersion,
|
||||
onDeleteVersion
|
||||
}: DocumentVersionsProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const renderVersionItem = (document: Document, isLatest: boolean) => {
|
||||
// Löschen nur anzeigen, wenn es mehr als eine Version gibt und es nicht die neueste ist
|
||||
// oder wenn es die einzige Version ist (nur zur Konsistenz)
|
||||
const canDelete = documents.length > 1 || !isLatest;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={document.id}
|
||||
style={[
|
||||
styles.versionItem,
|
||||
{ borderBottomColor: colors.border }
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{flex: 1}}
|
||||
activeOpacity={0.6}
|
||||
onPress={() => {
|
||||
console.log('Version auswählen:', document.id);
|
||||
onSelectVersion(document);
|
||||
}}
|
||||
>
|
||||
<View style={styles.versionHeader}>
|
||||
<View style={styles.versionBadge}>
|
||||
<Text style={styles.versionNumber}>v{document.version}</Text>
|
||||
</View>
|
||||
{isLatest && (
|
||||
<View style={[styles.latestBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.latestText}>Aktuell</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
</View>
|
||||
|
||||
<Text style={[styles.versionDate, { color: colors.text + '99' }]}>
|
||||
{formatDate(document.created_at)}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={[styles.versionPreview, { color: colors.text }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{document.content.substring(0, 150)}
|
||||
{document.content.length > 150 ? '...' : ''}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Löschen-Button außerhalb der Touchable-Fläche für den Artikel */}
|
||||
{canDelete && onDeleteVersion && (
|
||||
<TouchableOpacity
|
||||
style={[styles.deleteSeparateButton, { backgroundColor: colors.card }]}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
console.log("Löschen-Button separat wurde gedrückt für:", document.id);
|
||||
|
||||
// Direkter Aufruf für Testzwecke
|
||||
if (onDeleteVersion) {
|
||||
console.log("Rufe onDeleteVersion direkt auf für Dokument ID:", document.id);
|
||||
onDeleteVersion(document);
|
||||
|
||||
// Schließe das Modal nach einer kurzen Verzögerung
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 100);
|
||||
} else {
|
||||
console.error("onDeleteVersion ist nicht definiert!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash" size={18} color="red" />
|
||||
<Text style={styles.deleteButtonText}>Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isVisible}
|
||||
animationType="slide"
|
||||
transparent={false}
|
||||
>
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Dokumentversionen</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.versionsList}>
|
||||
{documents.map((document, index) => renderVersionItem(document, index === 0))}
|
||||
|
||||
{documents.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="document-outline" size={48} color={colors.text + '40'} />
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Dokumentversionen verfügbar
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
versionsList: {
|
||||
flex: 1,
|
||||
},
|
||||
versionItem: {
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
versionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
versionBadge: {
|
||||
backgroundColor: '#e0e0e0',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
versionNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
latestBadge: {
|
||||
marginLeft: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
latestText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
},
|
||||
deleteSeparateButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 8,
|
||||
marginHorizontal: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ff3b30',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: 'red',
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
versionDate: {
|
||||
fontSize: 13,
|
||||
marginBottom: 8,
|
||||
},
|
||||
versionPreview: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
29
chat/apps/mobile/components/EditScreenInfo.tsx
Normal file
29
chat/apps/mobile/components/EditScreenInfo.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
};
|
||||
108
chat/apps/mobile/components/MessageInput.tsx
Normal file
108
chat/apps/mobile/components/MessageInput.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import React, { useState, forwardRef, useImperativeHandle, useRef } from 'react';
|
||||
import { View, TextInput, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
|
||||
type MessageInputProps = {
|
||||
onSend: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
// Öffentliche Methoden über Ref
|
||||
export interface MessageInputRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
function MessageInput({ onSend, isLoading = false }, ref) {
|
||||
const [message, setMessage] = useState('');
|
||||
const { colors } = useTheme();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Stellt die focus-Methode über ref zur Verfügung
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const handleSend = () => {
|
||||
if (message.trim() && !isLoading) {
|
||||
onSend(message.trim());
|
||||
setMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
// Tastatur-Event-Handler für Enter-Taste (besonders wichtig für Web)
|
||||
const handleKeyPress = (e: any) => {
|
||||
// Prüfen auf Enter ohne Shift für Submit
|
||||
if (e.nativeEvent.key === 'Enter' && !e.nativeEvent.shiftKey) {
|
||||
e.preventDefault(); // Verhindert Zeilenumbruch
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, { color: colors.text, backgroundColor: colors.background }]}
|
||||
placeholder="Nachricht eingeben..."
|
||||
placeholderTextColor={colors.text + '80'}
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
editable={!isLoading}
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
onKeyPress={handleKeyPress}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleSend}
|
||||
disabled={!message.trim() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Ionicons name="send" size={20} color="#fff" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(0,0,0,0.1)',
|
||||
width: '100%',
|
||||
maxWidth: 1200,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
maxHeight: 120,
|
||||
marginRight: 8,
|
||||
},
|
||||
sendButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default MessageInput;
|
||||
97
chat/apps/mobile/components/MessageItem.tsx
Normal file
97
chat/apps/mobile/components/MessageItem.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import SkeletonLoader from './SkeletonLoader';
|
||||
import TypingIndicator from './TypingIndicator';
|
||||
|
||||
type MessageProps = {
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function MessageItem({
|
||||
text,
|
||||
sender,
|
||||
timestamp,
|
||||
isLoading = false
|
||||
}: MessageProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const isUser = sender === 'user';
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
isUser ? styles.userContainer : styles.aiContainer,
|
||||
{ backgroundColor: isUser ? colors.primary : colors.card }
|
||||
]}>
|
||||
{isLoading && sender === 'ai' ? (
|
||||
// Zeige Skeleton oder TypingIndicator wenn geladen wird
|
||||
<>
|
||||
<SkeletonLoader
|
||||
lines={4}
|
||||
style={styles.skeletonContainer}
|
||||
/>
|
||||
<TypingIndicator
|
||||
dotColor={colors.text + '80'}
|
||||
style={styles.typingIndicator}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Zeige die eigentliche Nachricht
|
||||
<Text style={[
|
||||
styles.messageText,
|
||||
{ color: isUser ? '#fff' : colors.text }
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text style={[
|
||||
styles.timestamp,
|
||||
{ color: isUser ? 'rgba(255,255,255,0.7)' : colors.text + '80' }
|
||||
]}>
|
||||
{timestamp.getHours().toString().padStart(2, '0')}:{timestamp.getMinutes().toString().padStart(2, '0')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 12,
|
||||
borderRadius: 16,
|
||||
marginVertical: 4,
|
||||
marginHorizontal: 12,
|
||||
},
|
||||
userContainer: {
|
||||
maxWidth: '80%',
|
||||
alignSelf: 'flex-start',
|
||||
borderBottomLeftRadius: 4,
|
||||
},
|
||||
aiContainer: {
|
||||
width: '95%',
|
||||
alignSelf: 'flex-end',
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
skeletonContainer: {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
opacity: 0.8,
|
||||
},
|
||||
typingIndicator: {
|
||||
marginLeft: -5,
|
||||
marginTop: 5,
|
||||
}
|
||||
});
|
||||
64
chat/apps/mobile/components/MessageList.tsx
Normal file
64
chat/apps/mobile/components/MessageList.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import { FlatList, StyleSheet, View } from 'react-native';
|
||||
import MessageItem from './MessageItem';
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
text: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
type MessageListProps = {
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export default function MessageList({ messages, isLoading = false }: MessageListProps) {
|
||||
const renderMessageItem = ({ item, index }: { item: Message, index: number }) => {
|
||||
// Wenn die Nachricht die letzte ist und vom KI-Assistenten stammt,
|
||||
// zeigen wir den Lade-Indikator an, wenn isLoading=true ist
|
||||
const isLastMessage = index === messages.length - 1;
|
||||
const isLastAIMessage = isLastMessage && item.sender === 'ai';
|
||||
const shouldShowLoading = isLoading && isLastAIMessage;
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
text={item.text}
|
||||
sender={item.sender}
|
||||
timestamp={item.timestamp}
|
||||
isLoading={shouldShowLoading || item.isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={messages}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderMessageItem}
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
inverted={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListFooterComponent={<View style={styles.footer} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
maxWidth: 800,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
contentContainer: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
footer: {
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
122
chat/apps/mobile/components/ModelCard.tsx
Normal file
122
chat/apps/mobile/components/ModelCard.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Model } from '../types';
|
||||
|
||||
type ModelCardProps = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
deployment?: string;
|
||||
isSelected?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
model?: Model; // Optionales komplettes Model-Objekt
|
||||
};
|
||||
|
||||
export default function ModelCard({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
model
|
||||
}: ModelCardProps) {
|
||||
const { colors } = useTheme();
|
||||
const deployment = model?.parameters?.deployment;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: isSelected ? colors.primary : 'transparent',
|
||||
}
|
||||
]}
|
||||
onPress={() => onSelect(id)}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons
|
||||
name="chatbubble-ellipses-outline"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<Text style={[styles.name, { color: colors.text }]}>{name}</Text>
|
||||
<Text
|
||||
style={[styles.description, { color: colors.text + '80' }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{deployment && (
|
||||
<Text
|
||||
style={[styles.deployment, { color: colors.primary + 'CC' }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{deployment}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isSelected && (
|
||||
<View style={[styles.checkmark, { backgroundColor: colors.primary }]}>
|
||||
<Ionicons name="checkmark" size={16} color="#fff" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
borderWidth: 2,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 4,
|
||||
},
|
||||
deployment: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
checkmark: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
161
chat/apps/mobile/components/ModelDropdown.tsx
Normal file
161
chat/apps/mobile/components/ModelDropdown.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, Modal, FlatList, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import { Model } from '../types';
|
||||
import { availableModels } from '../config/azure';
|
||||
import { getModels } from '../services/modelService';
|
||||
|
||||
// Verwende Modelle aus der Konfiguration
|
||||
const FALLBACK_MODELS: Model[] = availableModels;
|
||||
|
||||
type ModelDropdownProps = {
|
||||
selectedModelId: string;
|
||||
onSelectModel: (id: string) => void;
|
||||
};
|
||||
|
||||
export default function ModelDropdown({ selectedModelId, onSelectModel }: ModelDropdownProps) {
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [models, setModels] = useState<Model[]>(FALLBACK_MODELS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Lade die Modelle vom ModelService
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const modelsList = await getModels();
|
||||
setModels(modelsList);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Modelle:', err);
|
||||
setModels(FALLBACK_MODELS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
const selectedModel = models.find(model => model.id === selectedModelId) || models[0];
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
className={`flex-row items-center rounded-lg px-2 py-1 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-gray-100'}`}
|
||||
>
|
||||
<Text className={`text-sm font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
{selectedModel.name}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color={isDarkMode ? '#FFFFFF' : '#000000'}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View
|
||||
className={`mx-4 rounded-xl p-4 ${isDarkMode ? 'bg-[#1C1C1E]' : 'bg-white'}`}
|
||||
style={styles.modalContent}
|
||||
>
|
||||
<Text className={`text-lg font-bold mb-4 ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
Modell auswählen
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<View className="py-4 items-center">
|
||||
<Text className={`${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Modelle werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={models}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center p-3 mb-2 rounded-lg ${
|
||||
item.id === selectedModelId
|
||||
? isDarkMode ? 'bg-blue-900/30' : 'bg-blue-100'
|
||||
: isDarkMode ? 'bg-[#2C2C2E]' : 'bg-gray-100'
|
||||
}`}
|
||||
onPress={() => {
|
||||
onSelectModel(item.id);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<View className="w-8 h-8 rounded-full bg-blue-500/20 items-center justify-center mr-3">
|
||||
<Ionicons
|
||||
name="chatbubble-ellipses-outline"
|
||||
size={16}
|
||||
color="#0A84FF"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text className={`font-medium ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-xs mt-1 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
{item.parameters?.deployment && (
|
||||
<Text
|
||||
className={`text-xs mt-1 ${isDarkMode ? 'text-blue-400' : 'text-blue-500'}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.parameters.deployment}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{item.id === selectedModelId && (
|
||||
<View className="w-6 h-6 rounded-full bg-blue-500 items-center justify-center">
|
||||
<Ionicons name="checkmark" size={14} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
className={`mt-3 py-3 rounded-lg items-center ${isDarkMode ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]'}`}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-white font-medium">Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
maxHeight: '80%',
|
||||
},
|
||||
});
|
||||
46
chat/apps/mobile/components/NewChatButton.tsx
Normal file
46
chat/apps/mobile/components/NewChatButton.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, Text, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
|
||||
type NewChatButtonProps = {
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
export default function NewChatButton({ onPress }: NewChatButtonProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Ionicons name="add" size={24} color="#fff" style={styles.icon} />
|
||||
<Text style={styles.text}>Neuer Chat</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 30,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
icon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
text: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
81
chat/apps/mobile/components/SkeletonLoader.tsx
Normal file
81
chat/apps/mobile/components/SkeletonLoader.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Animated, Easing, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
|
||||
type SkeletonLoaderProps = {
|
||||
lines?: number;
|
||||
animated?: boolean;
|
||||
style?: any;
|
||||
};
|
||||
|
||||
export default function SkeletonLoader({
|
||||
lines = 3,
|
||||
animated = true,
|
||||
style
|
||||
}: SkeletonLoaderProps) {
|
||||
const { colors } = useTheme();
|
||||
const [fadeAnim] = useState(new Animated.Value(0.3));
|
||||
|
||||
useEffect(() => {
|
||||
if (animated) {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.8,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0.3,
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
}
|
||||
}, [fadeAnim, animated]);
|
||||
|
||||
// Erstelle verschiedene Längen für die Zeilen
|
||||
const getRandomWidth = (index: number) => {
|
||||
// Erste und letzte Zeile sind kürzer
|
||||
if (index === 0) return { width: '70%' };
|
||||
if (index === lines - 1) return { width: '40%' };
|
||||
|
||||
// Zufällige Breite für die Zeilen dazwischen
|
||||
const widths = ['85%', '90%', '75%', '95%'];
|
||||
return { width: widths[index % widths.length] };
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.line,
|
||||
getRandomWidth(index),
|
||||
{
|
||||
backgroundColor: colors.text + '20',
|
||||
opacity: fadeAnim,
|
||||
marginBottom: index === lines - 1 ? 0 : 8
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
maxWidth: '80%',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
line: {
|
||||
height: 15,
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
180
chat/apps/mobile/components/TemplateCard.tsx
Normal file
180
chat/apps/mobile/components/TemplateCard.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
|
||||
// Typ für die Template-Props
|
||||
interface TemplateCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
systemPrompt: string;
|
||||
color?: string;
|
||||
isDefault?: boolean;
|
||||
onPress: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onSetDefault?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function TemplateCard({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
systemPrompt,
|
||||
color = '#0A84FF',
|
||||
isDefault = false,
|
||||
onPress,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSetDefault
|
||||
}: TemplateCardProps) {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
const backgroundColor = isDarkMode ? '#2C2C2E' : '#FFFFFF';
|
||||
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
|
||||
const secondaryTextColor = isDarkMode ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)';
|
||||
|
||||
// Kürze den System-Prompt für die Anzeige
|
||||
const truncatedPrompt = systemPrompt.length > 80
|
||||
? systemPrompt.substring(0, 80) + '...'
|
||||
: systemPrompt;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor },
|
||||
isDefault && styles.defaultContainer
|
||||
]}
|
||||
onPress={() => onPress(id)}
|
||||
>
|
||||
{/* Farbiger Indikator am linken Rand */}
|
||||
<View style={[styles.colorIndicator, { backgroundColor: color }]} />
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.name, { color: textColor }]}>{name}</Text>
|
||||
|
||||
{isDefault && (
|
||||
<View style={styles.defaultBadge}>
|
||||
<Text style={styles.defaultText}>Standard</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{description && (
|
||||
<Text
|
||||
style={[styles.description, { color: secondaryTextColor }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[styles.prompt, { color: secondaryTextColor }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{truncatedPrompt}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Aktionen */}
|
||||
<View style={styles.actions}>
|
||||
{onSetDefault && !isDefault && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onSetDefault(id)}
|
||||
>
|
||||
<Ionicons name="star-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onEdit && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onEdit(id)}
|
||||
>
|
||||
<Ionicons name="pencil" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onDelete(id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color={isDarkMode ? '#FFFFFF' : '#666666'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
defaultContainer: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#0A84FF',
|
||||
},
|
||||
colorIndicator: {
|
||||
width: 8,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
defaultBadge: {
|
||||
backgroundColor: '#0A84FF',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 8,
|
||||
},
|
||||
defaultText: {
|
||||
color: 'white',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
prompt: {
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
actions: {
|
||||
padding: 8,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
padding: 8,
|
||||
},
|
||||
});
|
||||
417
chat/apps/mobile/components/TemplateForm.tsx
Normal file
417
chat/apps/mobile/components/TemplateForm.tsx
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import ModelDropdown from './ModelDropdown';
|
||||
import { Template } from '../services/template';
|
||||
|
||||
// Verfügbare Farben für Vorlagen
|
||||
const TEMPLATE_COLORS = [
|
||||
'#0A84FF', // Blau
|
||||
'#32D74B', // Grün
|
||||
'#FF375F', // Rot
|
||||
'#FF9F0A', // Orange
|
||||
'#5E5CE6', // Lila
|
||||
'#BF5AF2', // Pink
|
||||
'#64D2FF', // Hellblau
|
||||
'#30D158', // Grün
|
||||
'#FF453A', // Rot
|
||||
];
|
||||
|
||||
interface TemplateFormProps {
|
||||
initialData?: Partial<Template>;
|
||||
onSubmit: (data: Partial<Template>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function TemplateForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel
|
||||
}: TemplateFormProps) {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState(initialData?.name || '');
|
||||
const [description, setDescription] = useState(initialData?.description || '');
|
||||
const [systemPrompt, setSystemPrompt] = useState(initialData?.system_prompt || '');
|
||||
const [initialQuestion, setInitialQuestion] = useState(initialData?.initial_question || '');
|
||||
const [selectedColor, setSelectedColor] = useState(initialData?.color || TEMPLATE_COLORS[0]);
|
||||
const [selectedModelId, setSelectedModelId] = useState(initialData?.model_id || '');
|
||||
const [documentMode, setDocumentMode] = useState(initialData?.document_mode || false);
|
||||
|
||||
// Validierung
|
||||
const [errors, setErrors] = useState<{
|
||||
name?: string;
|
||||
systemPrompt?: string;
|
||||
}>({});
|
||||
|
||||
// Helpers
|
||||
const isEditMode = !!initialData?.id;
|
||||
const bgColor = isDarkMode ? '#1C1C1E' : '#FFFFFF';
|
||||
const textColor = isDarkMode ? '#FFFFFF' : '#000000';
|
||||
const placeholderColor = isDarkMode ? '#8E8E93' : '#C7C7CC';
|
||||
const borderColor = isDarkMode ? '#38383A' : '#E5E5EA';
|
||||
|
||||
// Validiere das Formular vor dem Absenden
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: {
|
||||
name?: string;
|
||||
systemPrompt?: string;
|
||||
} = {};
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Bitte gib einen Namen ein.';
|
||||
}
|
||||
|
||||
if (!systemPrompt.trim()) {
|
||||
newErrors.systemPrompt = 'Der System-Prompt darf nicht leer sein.';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
onSubmit({
|
||||
id: initialData?.id,
|
||||
name,
|
||||
description: description.trim() || null,
|
||||
system_prompt: systemPrompt,
|
||||
initial_question: initialQuestion.trim() || null,
|
||||
color: selectedColor,
|
||||
model_id: selectedModelId || null,
|
||||
document_mode: documentMode
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={[styles.container, { backgroundColor: bgColor }]}
|
||||
>
|
||||
<ScrollView style={styles.scrollView}>
|
||||
<View style={styles.form}>
|
||||
{/* Titel */}
|
||||
<Text style={[styles.title, { color: textColor }]}>
|
||||
{isEditMode ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}
|
||||
</Text>
|
||||
|
||||
{/* Name */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Name *</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: errors.name ? '#FF3B30' : borderColor
|
||||
}
|
||||
]}
|
||||
placeholder="Name der Vorlage"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
maxLength={50}
|
||||
/>
|
||||
{errors.name && (
|
||||
<Text style={styles.errorText}>{errors.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Beschreibung (optional)</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor
|
||||
}
|
||||
]}
|
||||
placeholder="Kurze Beschreibung dieser Vorlage"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
maxLength={200}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* System-Prompt */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>System-Prompt *</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: errors.systemPrompt ? '#FF3B30' : borderColor,
|
||||
height: 150
|
||||
}
|
||||
]}
|
||||
placeholder="System-Prompt für die KI"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={systemPrompt}
|
||||
onChangeText={setSystemPrompt}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{errors.systemPrompt && (
|
||||
<Text style={styles.errorText}>{errors.systemPrompt}</Text>
|
||||
)}
|
||||
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Der System-Prompt definiert die Rolle und das Verhalten der KI.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Initiale Frage */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Beispielfrage (optional)</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
color: textColor,
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: borderColor,
|
||||
height: 80
|
||||
}
|
||||
]}
|
||||
placeholder="Beispiel für eine passende Frage oder Anweisung"
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={initialQuestion}
|
||||
onChangeText={setInitialQuestion}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Diese Frage wird als Vorschlag angezeigt, wenn die Vorlage ausgewählt wird.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Farbe auswählen */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Farbe</Text>
|
||||
<View style={styles.colorPicker}>
|
||||
{TEMPLATE_COLORS.map((color) => (
|
||||
<TouchableOpacity
|
||||
key={color}
|
||||
style={[
|
||||
styles.colorOption,
|
||||
{ backgroundColor: color },
|
||||
selectedColor === color && styles.selectedColorOption
|
||||
]}
|
||||
onPress={() => setSelectedColor(color)}
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<Ionicons name="checkmark" size={16} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Modell auswählen */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Bevorzugtes Modell (optional)</Text>
|
||||
<ModelDropdown
|
||||
selectedModelId={selectedModelId}
|
||||
onSelectModel={setSelectedModelId}
|
||||
/>
|
||||
<Text style={[styles.helperText, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Falls ausgewählt, wird dieses Modell automatisch mit der Vorlage verwendet.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Dokumentmodus */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: textColor }]}>Dokumentmodus</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.switchContainer,
|
||||
{
|
||||
backgroundColor: documentMode ? colors.primary + '20' : isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: documentMode ? colors.primary : borderColor
|
||||
}
|
||||
]}
|
||||
onPress={() => setDocumentMode(!documentMode)}
|
||||
>
|
||||
<View style={styles.switchText}>
|
||||
<Text style={[styles.switchLabel, { color: textColor }]}>
|
||||
Dokumentmodus aktivieren
|
||||
</Text>
|
||||
<Text style={[styles.switchDescription, { color: isDarkMode ? '#8E8E93' : '#8A8A8E' }]}>
|
||||
Ermöglicht die Bearbeitung eines Dokuments während der Konversation
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[
|
||||
styles.switchButton,
|
||||
{ backgroundColor: documentMode ? colors.primary : isDarkMode ? '#636366' : '#C7C7CC' }
|
||||
]}>
|
||||
{documentMode ? (
|
||||
<Ionicons name="checkmark" size={14} color="white" />
|
||||
) : (
|
||||
<Ionicons name="close" size={14} color="white" />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton, { borderColor }]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: textColor }]}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.submitButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleSubmit}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: 'white' }]}>
|
||||
{isEditMode ? 'Speichern' : 'Erstellen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
form: {
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
},
|
||||
formGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
marginTop: 6,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: '#FF3B30',
|
||||
marginTop: 6,
|
||||
},
|
||||
colorPicker: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 10,
|
||||
},
|
||||
colorOption: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
margin: 5,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
selectedColorOption: {
|
||||
borderWidth: 2,
|
||||
borderColor: 'white',
|
||||
},
|
||||
switchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
},
|
||||
switchText: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
switchLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
switchDescription: {
|
||||
fontSize: 12,
|
||||
},
|
||||
switchButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 30,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
minWidth: 120,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: '#0A84FF',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
103
chat/apps/mobile/components/TypingIndicator.tsx
Normal file
103
chat/apps/mobile/components/TypingIndicator.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Animated, Easing } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
|
||||
type TypingIndicatorProps = {
|
||||
dotCount?: number;
|
||||
dotSize?: number;
|
||||
dotColor?: string;
|
||||
style?: any;
|
||||
};
|
||||
|
||||
export default function TypingIndicator({
|
||||
dotCount = 3,
|
||||
dotSize = 8,
|
||||
dotColor,
|
||||
style,
|
||||
}: TypingIndicatorProps) {
|
||||
const { colors } = useTheme();
|
||||
const [animations] = useState(() =>
|
||||
Array.from({ length: dotCount }).map(() => new Animated.Value(0))
|
||||
);
|
||||
|
||||
// Dotfarbe wird entweder von Prop oder vom Theme übernommen
|
||||
const actualDotColor = dotColor || colors.text;
|
||||
|
||||
useEffect(() => {
|
||||
// Animiere jeden Punkt mit einer Verzögerung
|
||||
const animateDots = () => {
|
||||
const animationSequence = animations.map((anim, i) =>
|
||||
Animated.sequence([
|
||||
// Verzögerung für jeden Punkt
|
||||
Animated.delay(i * 150),
|
||||
// Animation nach oben
|
||||
Animated.timing(anim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
// Animation zurück nach unten
|
||||
Animated.timing(anim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
// Verzögerung am Ende
|
||||
Animated.delay((dotCount - i - 1) * 150),
|
||||
])
|
||||
);
|
||||
|
||||
// Starte alle Animationen parallel und in einer Schleife
|
||||
Animated.loop(Animated.parallel(animationSequence)).start();
|
||||
};
|
||||
|
||||
animateDots();
|
||||
|
||||
// Cleanup beim Unmount
|
||||
return () => {
|
||||
animations.forEach(anim => anim.stopAnimation());
|
||||
};
|
||||
}, [animations, dotCount]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{animations.map((anim, index) => (
|
||||
<Animated.View
|
||||
key={index}
|
||||
style={[
|
||||
styles.dot,
|
||||
{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
backgroundColor: actualDotColor,
|
||||
borderRadius: dotSize / 2,
|
||||
marginHorizontal: dotSize / 3,
|
||||
transform: [
|
||||
{
|
||||
translateY: anim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, -dotSize],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 10,
|
||||
},
|
||||
dot: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue