mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 03:41:23 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
30
apps/context/apps/mobile/components/Button.tsx
Normal file
30
apps/context/apps/mobile/components/Button.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
|
||||
|
||||
type ButtonProps = {
|
||||
title: string;
|
||||
textClassName?: string;
|
||||
children?: React.ReactNode;
|
||||
} & TouchableOpacityProps;
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(
|
||||
({ title, textClassName, children, ...touchableProps }, ref) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
ref={ref}
|
||||
{...touchableProps}
|
||||
className={`${styles.button} ${touchableProps.className}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-center">
|
||||
<Text className={`${styles.buttonText} ${textClassName || ''}`}>{title}</Text>
|
||||
{children}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const styles = {
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
};
|
||||
9
apps/context/apps/mobile/components/Container.tsx
Normal file
9
apps/context/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',
|
||||
};
|
||||
29
apps/context/apps/mobile/components/EditScreenInfo.tsx
Normal file
29
apps/context/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`,
|
||||
};
|
||||
25
apps/context/apps/mobile/components/ScreenContent.tsx
Normal file
25
apps/context/apps/mobile/components/ScreenContent.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
import { EditScreenInfo } from './EditScreenInfo';
|
||||
|
||||
type ScreenContentProps = {
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||
title: `text-xl font-bold`,
|
||||
};
|
||||
100
apps/context/apps/mobile/components/ai/AIActionButton.tsx
Normal file
100
apps/context/apps/mobile/components/ai/AIActionButton.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface AIActionButtonProps {
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
isGenerating?: boolean;
|
||||
hasPrompt?: boolean;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export const AIActionButton: React.FC<AIActionButtonProps> = ({
|
||||
onPress,
|
||||
disabled = false,
|
||||
isGenerating = false,
|
||||
hasPrompt = false,
|
||||
style,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
// Bestimme den Button-Text basierend auf dem Kontext
|
||||
const getButtonText = () => {
|
||||
if (isGenerating) return 'Generiere...';
|
||||
if (hasPrompt) return 'Prompt senden';
|
||||
return 'Mit KI fortsetzen';
|
||||
};
|
||||
|
||||
// Bestimme das Button-Icon basierend auf dem Kontext
|
||||
const getButtonIcon = () => {
|
||||
if (isGenerating) return undefined;
|
||||
if (hasPrompt) return 'send';
|
||||
return 'sparkles-outline';
|
||||
};
|
||||
|
||||
// Bestimme die Hintergrundfarbe basierend auf dem Zustand
|
||||
const getBackgroundColor = () => {
|
||||
if (disabled) return isDark ? '#4b5563' : '#d1d5db';
|
||||
if (isPressed) return '#4338ca'; // Dunkleres Indigo für Pressed-State
|
||||
return '#4f46e5'; // Standard Indigo
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || isGenerating}
|
||||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: getBackgroundColor(),
|
||||
transform: [{ scale: pressed ? 0.98 : 1 }],
|
||||
opacity: disabled ? 0.7 : 1,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
{getButtonIcon() && (
|
||||
<Ionicons
|
||||
name={getButtonIcon() || 'sparkles-outline'}
|
||||
size={18}
|
||||
color="#ffffff"
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.buttonText}>{getButtonText()}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 16,
|
||||
elevation: 2,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 1.5,
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
480
apps/context/apps/mobile/components/ai/AIAssistant.tsx
Normal file
480
apps/context/apps/mobile/components/ai/AIAssistant.tsx
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, Modal, TouchableOpacity, ScrollView, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Button } from '~/components/Button';
|
||||
import { PromptEditor } from './PromptEditor';
|
||||
import { availableModels } from '~/services/aiService';
|
||||
import { useTheme, useThemeClasses, twMerge } from '~/utils/theme';
|
||||
import { createDocumentVersion } from '~/services/supabaseService';
|
||||
|
||||
type AIAssistantProps = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onInsertText: (text: string) => void;
|
||||
documentContent?: string;
|
||||
documentTitle?: string;
|
||||
documentId?: string;
|
||||
onVersionCreated?: (newDocumentId: string) => void;
|
||||
};
|
||||
|
||||
// Vordefinierte Prompts für verschiedene Aufgaben
|
||||
const predefinedPrompts = [
|
||||
{
|
||||
title: 'Text fortsetzen',
|
||||
prompt: 'Setze den folgenden Text fort, behalte dabei den Stil und Ton bei:\n\n',
|
||||
icon: 'create-outline',
|
||||
type: 'continuation',
|
||||
},
|
||||
{
|
||||
title: 'Zusammenfassen',
|
||||
prompt: 'Fasse den folgenden Text prägnant zusammen:\n\n',
|
||||
icon: 'list-outline',
|
||||
type: 'summary',
|
||||
},
|
||||
{
|
||||
title: 'Umformulieren',
|
||||
prompt: 'Formuliere den folgenden Text um, behalte dabei den Inhalt bei:\n\n',
|
||||
icon: 'sync-outline',
|
||||
type: 'rewrite',
|
||||
},
|
||||
{
|
||||
title: 'Ideen generieren',
|
||||
prompt: 'Generiere Ideen zum folgenden Thema:\n\n',
|
||||
icon: 'bulb-outline',
|
||||
type: 'ideas',
|
||||
},
|
||||
];
|
||||
|
||||
export const AIAssistant: React.FC<AIAssistantProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onInsertText,
|
||||
documentContent = '',
|
||||
documentTitle = '',
|
||||
documentId = '',
|
||||
onVersionCreated,
|
||||
}) => {
|
||||
const [showPromptEditor, setShowPromptEditor] = useState(false);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState('');
|
||||
const [selectedPromptType, setSelectedPromptType] = useState<
|
||||
'summary' | 'continuation' | 'rewrite' | 'ideas'
|
||||
>('summary');
|
||||
const [generatedText, setGeneratedText] = useState('');
|
||||
const [showOptionsModal, setShowOptionsModal] = useState(false);
|
||||
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
||||
const { mode, themeName, colors } = useTheme();
|
||||
const themeClasses = useThemeClasses();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
const handleSelectPrompt = (
|
||||
promptTemplate: string,
|
||||
promptType: 'summary' | 'continuation' | 'rewrite' | 'ideas'
|
||||
) => {
|
||||
// Füge den aktuellen Dokumenteninhalt zum Prompt hinzu, wenn vorhanden
|
||||
let fullPrompt = promptTemplate;
|
||||
|
||||
if (
|
||||
documentContent &&
|
||||
(promptTemplate.includes('folgenden Text') || promptTemplate.includes('Thema'))
|
||||
) {
|
||||
// Wenn es um Textfortsetzung, Zusammenfassung oder Umformulierung geht,
|
||||
// füge den aktuellen Dokumenteninhalt hinzu
|
||||
fullPrompt += documentContent;
|
||||
} else if (documentTitle) {
|
||||
// Ansonsten füge nur den Titel als Thema hinzu
|
||||
fullPrompt += documentTitle;
|
||||
}
|
||||
|
||||
setSelectedPrompt(fullPrompt);
|
||||
setSelectedPromptType(promptType);
|
||||
setShowPromptEditor(true);
|
||||
};
|
||||
|
||||
const handleGeneratedText = (
|
||||
text: string,
|
||||
model: string,
|
||||
insertionMode:
|
||||
| 'insert_at_cursor'
|
||||
| 'create_new_document'
|
||||
| 'replace_document'
|
||||
| 'insert_at_beginning'
|
||||
| 'insert_at_end'
|
||||
) => {
|
||||
// Text speichern und dann entsprechend verarbeiten
|
||||
setGeneratedText(text);
|
||||
|
||||
switch (insertionMode) {
|
||||
case 'create_new_document':
|
||||
// Neue Version des Dokuments erstellen
|
||||
handleCreateNewVersion(model, text);
|
||||
break;
|
||||
case 'replace_document':
|
||||
// Dokument ersetzen (gesamten Inhalt ersetzen)
|
||||
handleReplaceDocument(text);
|
||||
break;
|
||||
case 'insert_at_beginning':
|
||||
// Am Anfang des Dokuments einfügen
|
||||
handleInsertAtBeginning(text);
|
||||
break;
|
||||
case 'insert_at_end':
|
||||
// Am Ende des Dokuments einfügen
|
||||
handleInsertAtEnd(text);
|
||||
break;
|
||||
case 'insert_at_cursor':
|
||||
default:
|
||||
// An der Cursor-Position einfügen (Standard)
|
||||
handleInsertIntoCurrentDocument(text);
|
||||
break;
|
||||
}
|
||||
|
||||
setShowPromptEditor(false);
|
||||
};
|
||||
|
||||
// Text an der Cursor-Position einfügen (Standard)
|
||||
const handleInsertIntoCurrentDocument = (text: string = generatedText) => {
|
||||
onInsertText(text);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Text am Anfang des Dokuments einfügen
|
||||
const handleInsertAtBeginning = (text: string = generatedText) => {
|
||||
// Wir fügen einen speziellen Marker hinzu, der in der DocumentScreen-Komponente
|
||||
// erkannt wird, um den Text am Anfang einzufügen
|
||||
onInsertText(`__INSERT_AT_BEGINNING__${text}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Text am Ende des Dokuments einfügen
|
||||
const handleInsertAtEnd = (text: string = generatedText) => {
|
||||
// Wir fügen einen speziellen Marker hinzu, der in der DocumentScreen-Komponente
|
||||
// erkannt wird, um den Text am Ende einzufügen
|
||||
onInsertText(`__INSERT_AT_END__${text}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Dokument ersetzen (gesamten Inhalt ersetzen)
|
||||
const handleReplaceDocument = (text: string = generatedText) => {
|
||||
// Wir fügen einen speziellen Marker hinzu, der in der DocumentScreen-Komponente
|
||||
// erkannt wird, um den gesamten Inhalt zu ersetzen
|
||||
onInsertText(`__REPLACE_DOCUMENT__${text}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateNewVersion = async (
|
||||
model: string = availableModels[0]?.value || 'gpt-4.1',
|
||||
text: string = generatedText
|
||||
) => {
|
||||
if (!documentId) {
|
||||
Alert.alert(
|
||||
'Fehler',
|
||||
'Das Dokument muss zuerst gespeichert werden, bevor eine neue Version erstellt werden kann.',
|
||||
[{ text: 'OK', onPress: () => setShowOptionsModal(false) }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingVersion(true);
|
||||
|
||||
try {
|
||||
// Verwende das übergebene Modell oder das erste verfügbare als Fallback
|
||||
|
||||
console.log('Erstelle neue Version mit Text:', text);
|
||||
|
||||
const { data, error } = await createDocumentVersion(
|
||||
documentId,
|
||||
text, // Verwende den direkt übergebenen Text
|
||||
selectedPromptType,
|
||||
model,
|
||||
selectedPrompt
|
||||
);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Fehler', `Fehler beim Erstellen der neuen Version: ${error}`);
|
||||
} else if (data) {
|
||||
// Erfolgsmeldung anzeigen
|
||||
Alert.alert('Erfolg', 'Neue Dokumentversion wurde erstellt!', [{ text: 'OK' }]);
|
||||
|
||||
// Callback aufrufen, wenn vorhanden
|
||||
if (onVersionCreated) {
|
||||
onVersionCreated(data.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der neuen Version:', error);
|
||||
Alert.alert('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setIsCreatingVersion(false);
|
||||
setShowOptionsModal(false);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent={true} animationType="fade" onRequestClose={onClose}>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.5)' },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[800] : colors.gray[50],
|
||||
borderColor: isDark ? colors.gray[700] : colors.gray[200],
|
||||
shadowColor: isDark ? colors.gray[900] : colors.gray[400],
|
||||
},
|
||||
showPromptEditor ? styles.modalContentLarge : {},
|
||||
]}
|
||||
>
|
||||
{!showPromptEditor ? (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.header,
|
||||
{
|
||||
borderBottomColor: isDark ? colors.gray[700] : colors.gray[200],
|
||||
borderBottomWidth: 1,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
variant="h2"
|
||||
style={[
|
||||
styles.title,
|
||||
{ color: isDark ? colors.gray[100] : colors.gray[900], fontWeight: '600' },
|
||||
]}
|
||||
>
|
||||
KI-Assistent
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
style={[
|
||||
styles.closeButton,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[700] : colors.gray[200],
|
||||
borderRadius: 20,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name="close"
|
||||
size={20}
|
||||
color={isDark ? colors.gray[100] : colors.gray[900]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
styles.subtitle,
|
||||
{ color: isDark ? colors.gray[300] : colors.gray[700], marginTop: 8 },
|
||||
]}
|
||||
>
|
||||
Was möchtest du tun?
|
||||
</Text>
|
||||
|
||||
<ScrollView style={styles.promptList}>
|
||||
{predefinedPrompts.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.promptItem,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[700] : colors.gray[100],
|
||||
borderColor: isDark ? colors.gray[600] : colors.gray[300],
|
||||
},
|
||||
]}
|
||||
onPress={() =>
|
||||
handleSelectPrompt(
|
||||
item.prompt,
|
||||
item.type as 'summary' | 'continuation' | 'rewrite' | 'ideas'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Ionicons
|
||||
name={item.icon as any}
|
||||
size={24}
|
||||
color={isDark ? colors.accent[400] : colors.accent[600]}
|
||||
style={styles.promptIcon}
|
||||
/>
|
||||
<View style={styles.promptTextContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.promptTitle,
|
||||
{ color: isDark ? colors.gray[100] : colors.gray[900] },
|
||||
]}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.promptDescription,
|
||||
{ color: isDark ? colors.gray[400] : colors.gray[600] },
|
||||
]}
|
||||
>
|
||||
{item.prompt.split('\n\n')[0]}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={isDark ? colors.gray[400] : colors.gray[500]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSelectedPrompt('');
|
||||
setShowPromptEditor(true);
|
||||
}}
|
||||
style={[
|
||||
styles.customPromptButton,
|
||||
{ backgroundColor: isDark ? colors.primary[600] : colors.primary[500] },
|
||||
]}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons
|
||||
name="create-outline"
|
||||
size={18}
|
||||
color="#ffffff"
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
<Text style={styles.buttonText}>Eigenen Prompt eingeben</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<PromptEditor
|
||||
modelOptions={availableModels}
|
||||
onGeneratedText={handleGeneratedText}
|
||||
onClose={() => setShowPromptEditor(false)}
|
||||
initialPrompt={selectedPrompt}
|
||||
documentId={documentId}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Das Options-Modal wird nicht mehr benötigt, da die Auswahl direkt im PromptEditor erfolgt */}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
width: '90%',
|
||||
maxWidth: 500,
|
||||
maxHeight: '80%',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
borderWidth: 1,
|
||||
},
|
||||
modalContentLarge: {
|
||||
width: '95%',
|
||||
maxWidth: 800,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
optionsModalContent: {
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
borderWidth: 1,
|
||||
},
|
||||
optionsContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
optionButton: {
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 20,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 6,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
promptList: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
promptItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
promptIcon: {
|
||||
marginRight: 16,
|
||||
},
|
||||
promptTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
promptTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
promptDescription: {
|
||||
fontSize: 14,
|
||||
},
|
||||
customPromptButton: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
748
apps/context/apps/mobile/components/ai/BottomLLMToolbar.tsx
Normal file
748
apps/context/apps/mobile/components/ai/BottomLLMToolbar.tsx
Normal file
|
|
@ -0,0 +1,748 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import {
|
||||
availableModels,
|
||||
AIModelOption,
|
||||
generateText,
|
||||
AIProvider,
|
||||
checkTokenBalance,
|
||||
} from '~/services/aiService';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { getDocumentById } from '~/services/supabaseService';
|
||||
import { supabase } from '~/utils/supabase';
|
||||
import { eventEmitter, EVENTS } from '~/utils/eventEmitter';
|
||||
import { getCurrentTokenBalance } from '~/services/tokenTransactionService';
|
||||
import TokenDisplay from '~/components/monetization/TokenDisplay';
|
||||
import TokenEstimator from '~/components/monetization/TokenEstimator';
|
||||
|
||||
// Globaler Stil für das Entfernen des Fokus-Outlines
|
||||
if (typeof document !== 'undefined') {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.ai-input-no-focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
.ai-input-no-focus:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
import { AIActionButton } from './AIActionButton';
|
||||
|
||||
interface BottomLLMToolbarProps {
|
||||
onGenerateText: (generatedText: string, mode: 'append' | 'replace') => void;
|
||||
documentContent: string;
|
||||
isGenerating: boolean;
|
||||
setIsGenerating: (isGenerating: boolean) => void;
|
||||
documentId?: string; // Optional document ID für Token-Tracking
|
||||
}
|
||||
|
||||
export const BottomLLMToolbar: React.FC<BottomLLMToolbarProps> = ({
|
||||
onGenerateText,
|
||||
documentContent,
|
||||
isGenerating,
|
||||
setIsGenerating,
|
||||
documentId,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [selectedModel, setSelectedModel] = useState<AIModelOption>(availableModels[0]);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [promptText, setPromptText] = useState('');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [tokenEstimate, setTokenEstimate] = useState<any>(null);
|
||||
const [showTokenEstimator, setShowTokenEstimator] = useState(false);
|
||||
const [tokenBalance, setTokenBalance] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Funktion zum Extrahieren von Mentions aus dem Text
|
||||
const extractMentions = (text: string) => {
|
||||
// Regex für das Format [Dokumenttitel](dokumentId)
|
||||
const mentionRegex = /\[(.*?)\]\((.*?)\)/g;
|
||||
const mentions: { title: string; id: string }[] = [];
|
||||
|
||||
let match;
|
||||
while ((match = mentionRegex.exec(text)) !== null) {
|
||||
// Stellen Sie sicher, dass die ID ein gültiges UUID-Format hat (zur Vermeidung von falschen Treffern)
|
||||
if (match[2].match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
|
||||
mentions.push({
|
||||
title: match[1],
|
||||
id: match[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mentions;
|
||||
};
|
||||
|
||||
// Funktion zum Abrufen des Inhalts eines erwähnten Dokuments
|
||||
const fetchMentionedDocumentContent = async (documentId: string) => {
|
||||
try {
|
||||
const document = await getDocumentById(documentId);
|
||||
|
||||
if (!document) {
|
||||
return `[Dokument mit ID ${documentId} nicht gefunden]`;
|
||||
}
|
||||
|
||||
if (!document.content) {
|
||||
return `[Dokument "${document.title}" hat keinen Inhalt]`;
|
||||
}
|
||||
|
||||
return document.content;
|
||||
} catch (error) {
|
||||
return `[Fehler beim Abrufen des Dokuments mit ID ${documentId}]`;
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Ersetzen aller Mentions im Text mit dem tatsächlichen Dokumentinhalt
|
||||
const replaceMentionsWithContent = async (text: string) => {
|
||||
const mentions = extractMentions(text);
|
||||
let processedText = text;
|
||||
|
||||
// Wenn keine Mentions gefunden wurden, gib den Originaltext zurück
|
||||
if (mentions.length === 0) {
|
||||
return processedText;
|
||||
}
|
||||
|
||||
// Ersetze jede Mention durch den Dokumentinhalt
|
||||
for (const mention of mentions) {
|
||||
// Abrufen des Dokumentinhalts
|
||||
const documentContent = await fetchMentionedDocumentContent(mention.id);
|
||||
|
||||
// Erstelle ein Muster, das genau der Mention im Text entspricht
|
||||
const mentionText = `[${mention.title}](${mention.id})`;
|
||||
|
||||
// Ersetze die Mention durch den tatsächlichen Inhalt ohne Formatierungsmarker
|
||||
const newContent = `\n\n${documentContent}\n\n`;
|
||||
processedText = processedText.replace(mentionText, newContent);
|
||||
}
|
||||
|
||||
return processedText;
|
||||
};
|
||||
|
||||
// Funktion zur Ersetzung von Mentions im Text
|
||||
const processMentionsInText = async (text: string) => {
|
||||
return await replaceMentionsWithContent(text);
|
||||
};
|
||||
|
||||
const handleGenerateText = async (mode: 'append' | 'replace') => {
|
||||
if ((!documentContent.trim() && !promptText.trim()) || isGenerating) return;
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
// Verarbeite Mentions im Prompt-Text für das KI-Modell
|
||||
let processedPromptText = promptText;
|
||||
if (promptText.trim()) {
|
||||
processedPromptText = await processMentionsInText(promptText);
|
||||
}
|
||||
|
||||
// Für das KI-Modell verarbeiten wir auch Mentions im Dokumentinhalt
|
||||
let processedDocumentContent = documentContent;
|
||||
if (documentContent.trim()) {
|
||||
processedDocumentContent = await processMentionsInText(documentContent);
|
||||
}
|
||||
|
||||
// Erstelle den vollständigen Prompt für das KI-Modell
|
||||
let fullPrompt = '';
|
||||
|
||||
if (processedPromptText.trim()) {
|
||||
// Wenn ein benutzerdefinierter Prompt eingegeben wurde
|
||||
fullPrompt = processedPromptText;
|
||||
|
||||
// Füge den verarbeiteten Dokumentinhalt als Kontext hinzu, wenn vorhanden
|
||||
if (processedDocumentContent.trim()) {
|
||||
if (mode === 'append') {
|
||||
fullPrompt += `\n\nHier der vorhandene Text, bitte setze ihn fort:\n${processedDocumentContent}`;
|
||||
} else {
|
||||
fullPrompt += `\n\nHier der vorhandene Text, bitte formuliere ihn neu:\n${processedDocumentContent}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Wenn kein benutzerdefinierter Prompt, verwende den verarbeiteten Dokumentinhalt direkt
|
||||
if (mode === 'append') {
|
||||
fullPrompt = `${processedDocumentContent}\n\nBitte setze diesen Text fort.`;
|
||||
} else {
|
||||
fullPrompt = `${processedDocumentContent}\n\nBitte formuliere diesen Text neu.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the selected model to generate text with increased max tokens for complete responses
|
||||
let result = await generateText(fullPrompt, selectedModel.provider, {
|
||||
model: selectedModel.value,
|
||||
maxTokens: 2000,
|
||||
temperature: 0.7,
|
||||
documentId: documentId, // Für die Token-Nutzungsverfolgung
|
||||
});
|
||||
|
||||
// Extrahiere den generierten Text aus dem Ergebnisobjekt
|
||||
let generatedText = result.text;
|
||||
|
||||
// Füge eine Linie über die Antwort ein
|
||||
generatedText = `\n---\n${generatedText}`;
|
||||
|
||||
// Pass the generated text back to the parent component with the mode
|
||||
// Wichtig: Wir geben den generierten Text direkt weiter, ohne die Mentions im Dokument zu ersetzen
|
||||
onGenerateText(generatedText, mode);
|
||||
|
||||
// Zurücksetzen des Prompt-Texts nach erfolgreicher Generierung
|
||||
setPromptText('');
|
||||
|
||||
// Aktualisiere das Token-Guthaben nach erfolgreichem Call mit einer Verzögerung
|
||||
setTimeout(async () => {
|
||||
console.log('Aktualisiere Token-Guthaben nach erfolgreichem Call...');
|
||||
|
||||
// Direkte Aktualisierung des Token-Guthabens ohne Caching
|
||||
try {
|
||||
// Hole den aktuellen Benutzer
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const userId = sessionData?.session?.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Nicht angemeldet');
|
||||
}
|
||||
|
||||
// Hole das aktuelle Token-Guthaben direkt aus der Datenbank mit Cache-Busting
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('token_balance')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (userData) {
|
||||
console.log('Neues Token-Guthaben:', userData.token_balance);
|
||||
// Aktualisiere den Zustand mit dem neuen Guthaben
|
||||
setTokenBalance(userData.token_balance);
|
||||
|
||||
// Löse ein Event aus, um alle TokenDisplay-Komponenten zu benachrichtigen
|
||||
console.log('Löse TOKEN_BALANCE_UPDATED-Event aus');
|
||||
eventEmitter.emit(EVENTS.TOKEN_BALANCE_UPDATED);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim direkten Aktualisieren des Token-Guthabens:', error);
|
||||
// Fallback zur normalen Aktualisierung
|
||||
await updateTokenBalance();
|
||||
}
|
||||
}, 2000); // 2 Sekunden Verzögerung für mehr Zeit
|
||||
} catch (error) {
|
||||
console.error('Error generating text:', error);
|
||||
// You could add error handling here, such as displaying a toast message
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle the dropdown
|
||||
const toggleDropdown = () => {
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
};
|
||||
|
||||
// Funktion zum Schätzen der Token-Kosten
|
||||
const estimateTokenCost = async () => {
|
||||
if (!promptText.trim() && !documentContent.trim()) return;
|
||||
|
||||
try {
|
||||
// Erstelle den Basis-Prompt
|
||||
const basePrompt =
|
||||
promptText.trim() || 'Bitte setze diesen Text fort oder formuliere ihn neu:';
|
||||
|
||||
// Erstelle den vollständigen Prompt mit dem Dokumentinhalt
|
||||
let fullPrompt = basePrompt;
|
||||
|
||||
// Für die Tokenschätzung erstellen wir den vollständigen Prompt so, wie er an das Modell gesendet wird
|
||||
if (documentContent.trim()) {
|
||||
// Für 'append' oder 'replace' würde der Prompt unterschiedlich sein, aber für die Schätzung
|
||||
// verwenden wir die 'append'-Variante als Beispiel
|
||||
fullPrompt += `\n\nHier der vorhandene Text, bitte setze ihn fort:\n${documentContent}`;
|
||||
}
|
||||
|
||||
console.log('Schätze Token-Kosten für vollständigen Prompt mit Länge:', fullPrompt.length);
|
||||
|
||||
// Schätze die Token-Kosten für den vollständigen Prompt (inkl. Dokumentinhalt)
|
||||
const { estimate } = await checkTokenBalance(
|
||||
fullPrompt,
|
||||
selectedModel.value,
|
||||
2000 // Standard auf 2000 Tokens
|
||||
);
|
||||
|
||||
console.log('Token-Schätzung:', estimate);
|
||||
|
||||
// Speichere die Schätzung
|
||||
setTokenEstimate(estimate);
|
||||
|
||||
return estimate;
|
||||
} catch (error) {
|
||||
console.error('Error estimating token cost:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Aktualisieren des Token-Guthabens
|
||||
const updateTokenBalance = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Hole den aktuellen Benutzer
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const userId = sessionData?.session?.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Nicht angemeldet');
|
||||
}
|
||||
|
||||
// Hole das aktuelle Token-Guthaben
|
||||
const balance = await getCurrentTokenBalance(userId);
|
||||
setTokenBalance(balance);
|
||||
|
||||
// Löse ein Event aus, um alle TokenDisplay-Komponenten zu benachrichtigen
|
||||
console.log('updateTokenBalance: Löse TOKEN_BALANCE_UPDATED-Event aus');
|
||||
eventEmitter.emit(EVENTS.TOKEN_BALANCE_UPDATED);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Token-Guthabens:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effekt zum Laden des Token-Guthabens beim Start
|
||||
useEffect(() => {
|
||||
updateTokenBalance();
|
||||
}, []);
|
||||
|
||||
// Effekt zum Aktualisieren der Token-Schätzung, wenn sich der Prompt oder der Dokumentinhalt ändert
|
||||
useEffect(() => {
|
||||
// Wir verwenden einen Debounce, um nicht zu viele Anfragen zu senden
|
||||
const timer = setTimeout(async () => {
|
||||
if (promptText.trim() || documentContent.trim()) {
|
||||
await estimateTokenCost();
|
||||
} else {
|
||||
setTokenEstimate(null);
|
||||
}
|
||||
}, 500); // 500ms Debounce
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [promptText, documentContent, selectedModel]);
|
||||
|
||||
// Bestimme, ob wir auf einem schmalen Bildschirm sind
|
||||
const [isNarrowScreen, setIsNarrowScreen] = useState(false);
|
||||
|
||||
// Effekt zur Erkennung der Bildschirmbreite
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web' && typeof window !== 'undefined') {
|
||||
const handleResize = () => {
|
||||
setIsNarrowScreen(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
// Initial setzen
|
||||
handleResize();
|
||||
|
||||
// Event-Listener für Größenänderungen
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
} else {
|
||||
// Für mobile Plattformen setzen wir einen Standardwert
|
||||
setIsNarrowScreen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: isDark ? '#111827' : '#f9fafb' }]}>
|
||||
{/* Token-Estimator */}
|
||||
{showTokenEstimator && tokenEstimate && (
|
||||
<TokenEstimator
|
||||
estimate={tokenEstimate}
|
||||
estimatedCompletionLength={2000}
|
||||
onClose={() => setShowTokenEstimator(false)}
|
||||
isLoading={isGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Token-Anzeige entfernt von hier - wird im Prompt-Input angezeigt */}
|
||||
{/* Prompt Input mit Action Buttons */}
|
||||
<View
|
||||
style={[styles.promptRow, isNarrowScreen ? styles.promptRowNarrow : styles.promptRowWide]}
|
||||
>
|
||||
{/* Prompt-Eingabefeld */}
|
||||
<View
|
||||
style={[
|
||||
styles.promptInputContainer,
|
||||
{ backgroundColor: isDark ? '#374151' : '#f3f4f6' },
|
||||
isFocused && styles.promptInputContainerFocused,
|
||||
isNarrowScreen && { height: 48 },
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
value={promptText}
|
||||
onChangeText={setPromptText}
|
||||
placeholder="Gib deinen Prompt ein..."
|
||||
placeholderTextColor={isDark ? '#9ca3af' : '#6b7280'}
|
||||
style={[styles.promptInput, { color: isDark ? '#f9fafb' : '#111827' }]}
|
||||
multiline
|
||||
numberOfLines={1}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
selectionColor={isDark ? '#6366f1' : '#4f46e5'}
|
||||
cursorColor={isDark ? '#6366f1' : '#4f46e5'}
|
||||
className="ai-input-no-focus"
|
||||
/>
|
||||
|
||||
{/* Token-Counter im Prompt-Input rechts */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowTokenEstimator(true)}
|
||||
style={styles.tokenCounterContainer}
|
||||
>
|
||||
<View style={styles.tokenCounterContent}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: isDark ? '#f9fafb' : '#111827',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{loading ? '---' : tokenBalance?.toLocaleString() || '---'}
|
||||
</Text>
|
||||
{tokenEstimate?.appTokens && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
→ {Math.max(0, (tokenBalance || 0) - tokenEstimate.appTokens).toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons Container */}
|
||||
<View
|
||||
style={[
|
||||
styles.actionButtonsContainer,
|
||||
isNarrowScreen ? styles.actionButtonsContainerNarrow : {},
|
||||
]}
|
||||
>
|
||||
{/* Weiter Button */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: isGenerating
|
||||
? '#6b7280'
|
||||
: pressed
|
||||
? isDark
|
||||
? '#1f2937'
|
||||
: '#d1d5db'
|
||||
: isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb',
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
},
|
||||
!documentContent.trim() && !promptText.trim() && { opacity: 0.7 },
|
||||
isNarrowScreen && { flex: 1 },
|
||||
]}
|
||||
onPress={() => handleGenerateText('append')}
|
||||
disabled={isGenerating || (!documentContent.trim() && !promptText.trim())}
|
||||
>
|
||||
<View style={styles.actionButtonContent}>
|
||||
{isGenerating ? (
|
||||
<Text style={[styles.actionButtonText, { color: '#ffffff' }]}>Generiere...</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
style={[styles.actionButtonText, { color: isDark ? '#f9fafb' : '#111827' }]}
|
||||
>
|
||||
Weiter
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="arrow-forward-outline"
|
||||
size={18}
|
||||
color={isDark ? '#f9fafb' : '#111827'}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Ersetzen Button */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: isGenerating
|
||||
? '#6b7280'
|
||||
: pressed
|
||||
? isDark
|
||||
? '#1f2937'
|
||||
: '#d1d5db'
|
||||
: isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb',
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
},
|
||||
!documentContent.trim() && !promptText.trim() && { opacity: 0.7 },
|
||||
isNarrowScreen ? { marginLeft: 8 } : { marginLeft: 12 },
|
||||
]}
|
||||
onPress={() => handleGenerateText('replace')}
|
||||
disabled={isGenerating || (!documentContent.trim() && !promptText.trim())}
|
||||
>
|
||||
<View style={styles.actionButtonContent}>
|
||||
{isGenerating ? (
|
||||
<Text style={[styles.actionButtonText, { color: '#ffffff' }]}>Generiere...</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
style={[styles.actionButtonText, { color: isDark ? '#f9fafb' : '#111827' }]}
|
||||
>
|
||||
Ersetzen
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="arrow-up-outline"
|
||||
size={18}
|
||||
color={isDark ? '#f9fafb' : '#111827'}
|
||||
style={{ marginLeft: 8, transform: [{ rotate: '45deg' }] }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Model-Auswahl-Icon */}
|
||||
<View style={styles.modelSelectorContainer}>
|
||||
<TouchableOpacity style={styles.modelSelectorButton} onPress={toggleDropdown}>
|
||||
<Ionicons name="ellipsis-vertical" size={20} color={isDark ? '#f9fafb' : '#111827'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Horizontale Modellauswahl */}
|
||||
{isDropdownOpen && (
|
||||
<View style={[styles.modelList, { backgroundColor: isDark ? '#374151' : '#f3f4f6' }]}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.modelListContent}
|
||||
>
|
||||
{availableModels.map((model) => (
|
||||
<TouchableOpacity
|
||||
key={model.value}
|
||||
style={[
|
||||
styles.modelItem,
|
||||
selectedModel.value === model.value && {
|
||||
backgroundColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderColor: isDark ? '#6366f1' : '#4f46e5',
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
setSelectedModel(model);
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.modelItemText,
|
||||
{ color: isDark ? '#f9fafb' : '#111827' },
|
||||
selectedModel.value === model.value && {
|
||||
color: isDark ? '#a5b4fc' : '#4f46e5',
|
||||
fontWeight: '600',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{model.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255, 255, 255, 0.12)',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
zIndex: 100,
|
||||
// Add shadow
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 5,
|
||||
},
|
||||
fullWidthButton: {
|
||||
alignSelf: 'stretch',
|
||||
flex: 1,
|
||||
width: 'auto',
|
||||
},
|
||||
|
||||
promptRow: {
|
||||
maxWidth: 800,
|
||||
width: '100%',
|
||||
marginHorizontal: 'auto',
|
||||
},
|
||||
promptRowWide: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
promptRowNarrow: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
modelSelectorContainer: {
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
marginLeft: 8,
|
||||
},
|
||||
modelSelectorButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 40,
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
|
||||
modelList: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 40,
|
||||
minWidth: 250,
|
||||
maxWidth: 400,
|
||||
borderRadius: 6,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 5,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
modelListContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
modelItem: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
marginHorizontal: 4,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
modelItemText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
metadataContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
tokenCounterContainer: {
|
||||
position: 'absolute',
|
||||
right: 10,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
tokenCounterContent: {
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tokenCounterText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
},
|
||||
tokenCounterEstimate: {
|
||||
fontSize: 8,
|
||||
marginTop: 2,
|
||||
},
|
||||
promptInputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 0,
|
||||
borderRadius: 0,
|
||||
flex: 1,
|
||||
height: 60,
|
||||
marginRight: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
promptInputContainerFocused: {
|
||||
borderColor: 'rgba(255, 255, 255, 0.4)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
promptInput: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
textAlignVertical: 'center',
|
||||
minHeight: 40,
|
||||
maxHeight: 100,
|
||||
},
|
||||
actionButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButtonsContainerNarrow: {
|
||||
marginTop: 8,
|
||||
width: '100%',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
actionButton: {
|
||||
height: 48,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 0,
|
||||
paddingHorizontal: 16,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
actionButtonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actionButtonText: {
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
91
apps/context/apps/mobile/components/ai/ModelSelector.tsx
Normal file
91
apps/context/apps/mobile/components/ai/ModelSelector.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { AIModelOption } from '~/services/aiService';
|
||||
import { useTheme } from '~/utils/theme';
|
||||
|
||||
type ModelSelectorProps = {
|
||||
modelOptions: AIModelOption[];
|
||||
selectedModel: string;
|
||||
onSelectModel: (modelValue: string) => void;
|
||||
};
|
||||
|
||||
export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
modelOptions,
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
}) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>Modell auswählen:</Text>
|
||||
<View style={styles.buttonContainer}>
|
||||
{modelOptions.map((model) => (
|
||||
<TouchableOpacity
|
||||
key={model.value}
|
||||
style={[
|
||||
styles.modelButton,
|
||||
selectedModel === model.value ? styles.modelButtonSelected : {},
|
||||
isDark ? styles.modelButtonDark : {},
|
||||
]}
|
||||
onPress={() => onSelectModel(model.value)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.modelButtonText,
|
||||
selectedModel === model.value ? styles.modelButtonTextSelected : {},
|
||||
isDark ? styles.modelButtonTextDark : {},
|
||||
]}
|
||||
>
|
||||
{model.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
marginBottom: 8,
|
||||
fontWeight: '500',
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
modelButton: {
|
||||
backgroundColor: '#f3f4f6',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 4,
|
||||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
},
|
||||
modelButtonDark: {
|
||||
backgroundColor: '#374151',
|
||||
borderColor: '#4b5563',
|
||||
},
|
||||
modelButtonSelected: {
|
||||
backgroundColor: '#818cf8',
|
||||
borderColor: '#6366f1',
|
||||
},
|
||||
modelButtonText: {
|
||||
color: '#4b5563',
|
||||
fontWeight: '500',
|
||||
},
|
||||
modelButtonTextDark: {
|
||||
color: '#d1d5db',
|
||||
},
|
||||
modelButtonTextSelected: {
|
||||
color: '#ffffff',
|
||||
},
|
||||
});
|
||||
528
apps/context/apps/mobile/components/ai/PromptEditor.tsx
Normal file
528
apps/context/apps/mobile/components/ai/PromptEditor.tsx
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { Button } from '~/components/Button';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { LoadingScreen } from '~/components/ui/LoadingScreen';
|
||||
import { generateText, AIModelOption, AIProvider, getProviderForModel } from '~/services/aiService';
|
||||
import { useTheme, useThemeClasses, twMerge } from '~/utils/theme';
|
||||
|
||||
// Definiert die verschiedenen Aktionen, die nach der Textgenerierung möglich sind
|
||||
type TextInsertionMode =
|
||||
| 'insert_at_cursor' // An der Cursor-Position einfügen (Standard)
|
||||
| 'create_new_document' // Neues Dokument erstellen
|
||||
| 'replace_document' // Dokument ersetzen
|
||||
| 'insert_at_beginning' // Am Anfang einfügen
|
||||
| 'insert_at_end'; // Am Ende einfügen
|
||||
|
||||
type PromptEditorProps = {
|
||||
onGeneratedText: (text: string, model: string, insertionMode: TextInsertionMode) => void;
|
||||
onClose?: () => void;
|
||||
modelOptions: AIModelOption[];
|
||||
initialPrompt?: string;
|
||||
documentId?: string;
|
||||
};
|
||||
|
||||
export const PromptEditor: React.FC<PromptEditorProps> = ({
|
||||
onGeneratedText,
|
||||
onClose,
|
||||
modelOptions,
|
||||
initialPrompt = '',
|
||||
documentId,
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
const [selectedModel, setSelectedModel] = useState(modelOptions[0]?.value || '');
|
||||
const [insertionMode, setInsertionMode] = useState<TextInsertionMode>('insert_at_cursor');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { mode, colors } = useTheme();
|
||||
const themeClasses = useThemeClasses();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Bestimme den Provider basierend auf dem ausgewählten Modell
|
||||
const provider = getProviderForModel(selectedModel);
|
||||
|
||||
const result = await generateText(prompt, provider, {
|
||||
model: selectedModel,
|
||||
temperature: 0.7,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
if (onGeneratedText) {
|
||||
onGeneratedText(result.text, selectedModel, insertionMode);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler bei der Textgenerierung');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[800] : colors.gray[50],
|
||||
borderColor: isDark ? colors.gray[700] : colors.gray[200],
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.header,
|
||||
{
|
||||
borderBottomColor: isDark ? colors.gray[700] : colors.gray[200],
|
||||
borderBottomWidth: 1,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
variant="h3"
|
||||
style={[
|
||||
styles.title,
|
||||
{ color: isDark ? colors.gray[100] : colors.gray[900], fontWeight: '600' },
|
||||
]}
|
||||
>
|
||||
KI-Textgenerierung
|
||||
</Text>
|
||||
{onClose && (
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
style={[
|
||||
styles.closeButton,
|
||||
{ backgroundColor: isDark ? colors.gray[700] : colors.gray[200], borderRadius: 20 },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="close" size={20} color={isDark ? colors.gray[100] : colors.gray[900]} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.inputLabel, { color: isDark ? colors.gray[300] : colors.gray[700] }]}>
|
||||
Prompt:
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.promptInput,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[700] : colors.gray[100],
|
||||
color: isDark ? colors.gray[100] : colors.gray[900],
|
||||
borderColor: isDark ? colors.gray[600] : colors.gray[300],
|
||||
},
|
||||
]}
|
||||
multiline
|
||||
placeholder="Beschreibe, welchen Text die KI generieren soll..."
|
||||
placeholderTextColor={isDark ? colors.gray[400] : colors.gray[500]}
|
||||
value={prompt}
|
||||
onChangeText={setPrompt}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Modellauswahl */}
|
||||
<View style={styles.modelSelector}>
|
||||
<Text style={[styles.inputLabel, { color: isDark ? colors.gray[300] : colors.gray[700] }]}>
|
||||
Modell auswählen:
|
||||
</Text>
|
||||
<View style={styles.modelButtons}>
|
||||
{modelOptions.map((model) => (
|
||||
<TouchableOpacity
|
||||
key={model.value}
|
||||
style={[
|
||||
styles.modelButton,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[700] : colors.gray[100],
|
||||
borderColor: isDark ? colors.gray[600] : colors.gray[300],
|
||||
},
|
||||
selectedModel === model.value
|
||||
? {
|
||||
backgroundColor: isDark ? colors.primary[600] : colors.primary[500],
|
||||
borderColor: isDark ? colors.primary[500] : colors.primary[400],
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
onPress={() => setSelectedModel(model.value)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.modelButtonText,
|
||||
{ color: isDark ? colors.gray[300] : colors.gray[700] },
|
||||
selectedModel === model.value ? { color: '#ffffff' } : {},
|
||||
]}
|
||||
>
|
||||
{model.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{documentId && (
|
||||
<View style={styles.optionsContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.inputLabel,
|
||||
{ color: isDark ? colors.gray[300] : colors.gray[700], marginBottom: 8 },
|
||||
]}
|
||||
>
|
||||
Nach der Generierung:
|
||||
</Text>
|
||||
|
||||
{/* Optionen für die Texteinfügung */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.optionsScrollView}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionButton,
|
||||
insertionMode === 'insert_at_cursor' && {
|
||||
backgroundColor: isDark ? colors.primary[700] : colors.primary[100],
|
||||
borderColor: isDark ? colors.primary[600] : colors.primary[400],
|
||||
},
|
||||
]}
|
||||
onPress={() => setInsertionMode('insert_at_cursor')}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons
|
||||
name="create-outline"
|
||||
size={16}
|
||||
color={isDark ? colors.gray[200] : colors.gray[800]}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionButtonText,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
An Cursor einfügen
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionButton,
|
||||
insertionMode === 'insert_at_beginning' && {
|
||||
backgroundColor: isDark ? colors.primary[700] : colors.primary[100],
|
||||
borderColor: isDark ? colors.primary[600] : colors.primary[400],
|
||||
},
|
||||
]}
|
||||
onPress={() => setInsertionMode('insert_at_beginning')}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons
|
||||
name="arrow-up-outline"
|
||||
size={16}
|
||||
color={isDark ? colors.gray[200] : colors.gray[800]}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionButtonText,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
Am Anfang einfügen
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionButton,
|
||||
insertionMode === 'insert_at_end' && {
|
||||
backgroundColor: isDark ? colors.primary[700] : colors.primary[100],
|
||||
borderColor: isDark ? colors.primary[600] : colors.primary[400],
|
||||
},
|
||||
]}
|
||||
onPress={() => setInsertionMode('insert_at_end')}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons
|
||||
name="arrow-down-outline"
|
||||
size={16}
|
||||
color={isDark ? colors.gray[200] : colors.gray[800]}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionButtonText,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
Am Ende einfügen
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionButton,
|
||||
insertionMode === 'replace_document' && {
|
||||
backgroundColor: isDark ? colors.warning[700] : colors.warning[100],
|
||||
borderColor: isDark ? colors.warning[600] : colors.warning[400],
|
||||
},
|
||||
]}
|
||||
onPress={() => setInsertionMode('replace_document')}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons
|
||||
name="refresh-outline"
|
||||
size={16}
|
||||
color={isDark ? colors.gray[200] : colors.gray[800]}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionButtonText,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
Dokument ersetzen
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionButton,
|
||||
insertionMode === 'create_new_document' && {
|
||||
backgroundColor: isDark ? colors.accent[700] : colors.accent[100],
|
||||
borderColor: isDark ? colors.accent[600] : colors.accent[400],
|
||||
},
|
||||
]}
|
||||
onPress={() => setInsertionMode('create_new_document')}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons
|
||||
name="copy-outline"
|
||||
size={16}
|
||||
color={isDark ? colors.gray[200] : colors.gray[800]}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionButtonText,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
Neue Version erstellen
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.generateButton,
|
||||
{
|
||||
backgroundColor: isDark ? colors.primary[600] : colors.primary[500],
|
||||
opacity: loading ? 0.7 : 1,
|
||||
},
|
||||
]}
|
||||
onPress={handleGenerate}
|
||||
disabled={loading || !prompt.trim()}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#ffffff" style={styles.buttonIcon} />
|
||||
) : (
|
||||
<Ionicons name="flash-outline" size={18} color="#ffffff" style={styles.buttonIcon} />
|
||||
)}
|
||||
<Text style={styles.buttonText}>{loading ? 'Generiere...' : 'Generieren'}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<LoadingScreen
|
||||
visible={loading}
|
||||
title="KI generiert Text"
|
||||
message="Die KI verarbeitet Ihren Prompt und generiert einen Text. Dies kann je nach Länge und Komplexität des Prompts einige Sekunden dauern."
|
||||
icon={{
|
||||
name: 'flash-outline',
|
||||
color: isDark ? colors.primary[400] : colors.primary[500],
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<View
|
||||
style={[
|
||||
styles.errorContainer,
|
||||
{
|
||||
backgroundColor: isDark ? colors.error[900] : colors.error[100],
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: isDark ? colors.error[700] : colors.error[500],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name="alert-circle"
|
||||
size={20}
|
||||
color={isDark ? colors.error[400] : colors.error[600]}
|
||||
/>
|
||||
<Text style={[styles.error, { color: isDark ? colors.error[400] : colors.error[700] }]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 20,
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: 18,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 6,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
flex: 1,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 6,
|
||||
},
|
||||
promptInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
minHeight: 250,
|
||||
height: '80%',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
modelSelector: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
modelButtons: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
modelButton: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 8,
|
||||
marginRight: 10,
|
||||
marginBottom: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
modelButtonText: {
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
},
|
||||
generateButton: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#ffffff',
|
||||
fontWeight: '500',
|
||||
fontSize: 16,
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 16,
|
||||
},
|
||||
optionsContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
optionsScrollView: {
|
||||
flexGrow: 0,
|
||||
marginBottom: 8,
|
||||
},
|
||||
optionButton: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginRight: 8,
|
||||
backgroundColor: 'transparent',
|
||||
minWidth: 140,
|
||||
},
|
||||
optionButtonText: {
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
},
|
||||
loaderContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
},
|
||||
loaderText: {
|
||||
marginTop: 12,
|
||||
fontWeight: '500',
|
||||
fontSize: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
error: {
|
||||
marginLeft: 10,
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
1048
apps/context/apps/mobile/components/ai/SpacesLLMToolbar.tsx
Normal file
1048
apps/context/apps/mobile/components/ai/SpacesLLMToolbar.tsx
Normal file
File diff suppressed because it is too large
Load diff
98
apps/context/apps/mobile/components/auth/LoginForm.tsx
Normal file
98
apps/context/apps/mobile/components/auth/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useState } from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Input } from '../ui/Input';
|
||||
import { Button } from '../Button';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
type LoginFormProps = {
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const LoginForm = ({ onSuccess }: LoginFormProps) => {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { signIn } = useAuth();
|
||||
|
||||
const handleLogin = async () => {
|
||||
// Reset error state
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Verwende die signIn-Funktion aus dem AuthContext
|
||||
const { success, error: authError } = await signIn(email, password);
|
||||
|
||||
if (success) {
|
||||
// Handle successful login
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.replace('/');
|
||||
}
|
||||
} else {
|
||||
setError(authError || 'Anmeldung fehlgeschlagen. Bitte überprüfe deine Anmeldedaten.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError('Anmeldung fehlgeschlagen. Bitte überprüfe deine Anmeldedaten.');
|
||||
console.error('Login error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="w-full">
|
||||
{error && (
|
||||
<View className="mb-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<Text className="text-red-800 dark:text-red-200">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="E-Mail"
|
||||
placeholder="deine@email.de"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Passwort"
|
||||
placeholder="Dein Passwort"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={loading ? 'Anmelden...' : 'Anmelden'}
|
||||
onPress={handleLogin}
|
||||
disabled={loading || !email || !password}
|
||||
className={loading || !email || !password ? 'opacity-70' : ''}
|
||||
>
|
||||
{loading && <ActivityIndicator size="small" color="#ffffff" style={{ marginLeft: 8 }} />}
|
||||
</Button>
|
||||
|
||||
<View className="mt-4 items-center">
|
||||
<Text className="text-gray-600 dark:text-gray-400">
|
||||
Noch kein Konto?{' '}
|
||||
<Text
|
||||
className="text-indigo-600 dark:text-indigo-400 font-semibold"
|
||||
onPress={() => router.push('/register')}
|
||||
>
|
||||
Registrieren
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
32
apps/context/apps/mobile/components/auth/ProtectedRoute.tsx
Normal file
32
apps/context/apps/mobile/components/auth/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
type ProtectedRouteProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
// Benutzer ist nicht angemeldet, leite zur Login-Seite weiter
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
// Zeige Ladeindikator während die Authentifizierung geprüft wird
|
||||
return (
|
||||
<View className="flex-1 justify-center items-center bg-gray-50 dark:bg-gray-900">
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Wenn der Benutzer angemeldet ist, zeige die geschützten Inhalte an
|
||||
return user ? <>{children}</> : null;
|
||||
};
|
||||
147
apps/context/apps/mobile/components/auth/RegisterForm.tsx
Normal file
147
apps/context/apps/mobile/components/auth/RegisterForm.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useState } from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Input } from '../ui/Input';
|
||||
import { Button } from '../Button';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
type RegisterFormProps = {
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const RegisterForm = ({ onSuccess }: RegisterFormProps) => {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const { signUp } = useAuth();
|
||||
|
||||
const handleRegister = async () => {
|
||||
// Reset states
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
// Validate inputs
|
||||
if (!name || !email || !password || !confirmPassword) {
|
||||
setError('Bitte fülle alle Felder aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Die Passwörter stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Das Passwort muss mindestens 6 Zeichen lang sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Verwende die signUp-Funktion aus dem AuthContext
|
||||
const { success, error: authError } = await signUp(email, password, name);
|
||||
|
||||
if (success) {
|
||||
if (authError) {
|
||||
// Wenn die Registrierung erfolgreich war, aber eine E-Mail-Bestätigung erforderlich ist
|
||||
setSuccessMessage(authError);
|
||||
} else {
|
||||
// Handle successful registration
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.replace('/');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(authError || 'Registrierung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError('Registrierung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
console.error('Registration error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="w-full">
|
||||
{error && (
|
||||
<View className="mb-4 p-3 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<Text className="text-red-800 dark:text-red-200">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<View className="mb-4 p-3 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<Text className="text-green-800 dark:text-green-200">{successMessage}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="Dein Name"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="E-Mail"
|
||||
placeholder="deine@email.de"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Passwort"
|
||||
placeholder="Dein Passwort"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Passwort bestätigen"
|
||||
placeholder="Passwort wiederholen"
|
||||
secureTextEntry
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={loading ? 'Registrieren...' : 'Registrieren'}
|
||||
onPress={handleRegister}
|
||||
disabled={loading || !name || !email || !password || !confirmPassword}
|
||||
className={loading || !name || !email || !password || !confirmPassword ? 'opacity-70' : ''}
|
||||
>
|
||||
{loading && <ActivityIndicator size="small" color="#ffffff" style={{ marginLeft: 8 }} />}
|
||||
</Button>
|
||||
|
||||
<View className="mt-4 items-center">
|
||||
<Text className="text-gray-600 dark:text-gray-400">
|
||||
Bereits ein Konto?{' '}
|
||||
<Text
|
||||
className="text-indigo-600 dark:text-indigo-400 font-semibold"
|
||||
onPress={() => router.push('/login')}
|
||||
>
|
||||
Anmelden
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,889 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { ThemedButton } from '~/components/ui/ThemedButton';
|
||||
import { LoadingScreen } from '~/components/ui/LoadingScreen';
|
||||
import {
|
||||
generateText,
|
||||
availableModels,
|
||||
AIModelOption,
|
||||
getProviderForModel,
|
||||
} from '~/services/aiService';
|
||||
import {
|
||||
createDocument,
|
||||
getDocuments,
|
||||
Document,
|
||||
getDocumentById,
|
||||
} from '~/services/supabaseService';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
interface BatchDocumentCreatorProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
spaceId: string;
|
||||
onDocumentsCreated?: () => void;
|
||||
}
|
||||
|
||||
export const BatchDocumentCreator: React.FC<BatchDocumentCreatorProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
spaceId,
|
||||
onDocumentsCreated,
|
||||
}) => {
|
||||
const [basePrompt, setBasePrompt] = useState('');
|
||||
const [subjects, setSubjects] = useState('');
|
||||
const [promptSuffix, setPromptSuffix] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState(availableModels[0]?.value || '');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [subjectList, setSubjectList] = useState<string[]>([]);
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
|
||||
const [documentFilter, setDocumentFilter] = useState<
|
||||
'all' | 'text' | 'context' | 'prompt'
|
||||
>('context');
|
||||
const [promptDocuments, setPromptDocuments] = useState<Document[]>([]);
|
||||
const { mode, colors } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
const router = useRouter();
|
||||
|
||||
// Lade die Dokumente aus dem aktuellen Space
|
||||
useEffect(() => {
|
||||
if (visible && spaceId) {
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
const docs = await getDocuments(spaceId);
|
||||
setDocuments(docs);
|
||||
// Filtere Prompt-Dokumente für die separate Anzeige
|
||||
setPromptDocuments(docs.filter((doc) => doc.type === 'prompt'));
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Dokumente:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocuments();
|
||||
}
|
||||
}, [visible, spaceId]);
|
||||
|
||||
// Funktion zum Aufteilen der Subjekte
|
||||
const parseSubjects = (): string[] => {
|
||||
return subjects
|
||||
.split(',')
|
||||
.map((subject) => subject.trim())
|
||||
.filter((subject) => subject.length > 0);
|
||||
};
|
||||
|
||||
// Funktion zum Generieren und Erstellen der Dokumente
|
||||
const handleCreateDocuments = async () => {
|
||||
const parsedSubjects = parseSubjects();
|
||||
|
||||
if (!basePrompt.trim()) {
|
||||
setError('Bitte geben Sie einen Basis-Prompt ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedSubjects.length === 0) {
|
||||
setError('Bitte geben Sie mindestens ein Subjekt ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubjectList(parsedSubjects);
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setProgress({ current: 0, total: parsedSubjects.length });
|
||||
|
||||
const createdDocumentIds: string[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < parsedSubjects.length; i++) {
|
||||
const subject = parsedSubjects[i];
|
||||
setProgress({ current: i + 1, total: parsedSubjects.length });
|
||||
|
||||
// Erstelle den vollständigen Prompt
|
||||
let fullPrompt = `${basePrompt} ${subject}${promptSuffix ? ' ' + promptSuffix : ''}`;
|
||||
|
||||
// Füge ausgewählte Dokumente als Kontext hinzu
|
||||
if (selectedDocuments.length > 0) {
|
||||
let contextContent = '';
|
||||
|
||||
for (const docId of selectedDocuments) {
|
||||
const doc = await getDocumentById(docId);
|
||||
if (doc && doc.content) {
|
||||
contextContent += `\n\n${doc.title}:\n${doc.content}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (contextContent) {
|
||||
fullPrompt += `\n\nHier dazu noch kontext: ${contextContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Bestimme den Provider basierend auf dem ausgewählten Modell
|
||||
const provider = getProviderForModel(selectedModel);
|
||||
|
||||
// Generiere Text mit dem ausgewählten KI-Modell
|
||||
const result = await generateText(fullPrompt, provider, {
|
||||
model: selectedModel,
|
||||
});
|
||||
|
||||
// Erstelle das Dokument in der Datenbank
|
||||
const { data, error } = await createDocument(
|
||||
result.text, // Inhalt ist der generierte Text
|
||||
'text' as 'text' | 'context' | 'prompt', // Typ ist "text"
|
||||
spaceId, // Space-ID
|
||||
{
|
||||
title: subject, // Titel des Dokuments ist das Subjekt
|
||||
// Metadaten
|
||||
prompt: fullPrompt,
|
||||
model: selectedModel,
|
||||
batchGenerated: true,
|
||||
basePrompt,
|
||||
promptSuffix,
|
||||
subject,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error(`Fehler beim Erstellen des Dokuments für ${subject}:`, error);
|
||||
} else if (data) {
|
||||
createdDocumentIds.push(data.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Erfolg - schließe den Dialog automatisch und zeige dann die Erfolgsmeldung
|
||||
onClose();
|
||||
if (onDocumentsCreated) {
|
||||
onDocumentsCreated();
|
||||
}
|
||||
|
||||
// Zeige die Erfolgsmeldung nach einer kurzen Verzögerung, damit die Spaces-Seite aktualisiert werden kann
|
||||
setTimeout(() => {
|
||||
Alert.alert(
|
||||
'Erfolg',
|
||||
`${createdDocumentIds.length} Dokumente wurden erfolgreich erstellt.`
|
||||
);
|
||||
}, 300);
|
||||
} catch (err) {
|
||||
console.error('Fehler bei der Batch-Erstellung:', err);
|
||||
setError('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal visible={visible} transparent={true} animationType="slide" onRequestClose={onClose}>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.5)' },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[800] : colors.gray[50],
|
||||
borderColor: isDark ? colors.gray[700] : colors.gray[200],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={[
|
||||
styles.header,
|
||||
{ borderBottomColor: isDark ? colors.gray[700] : colors.gray[200] },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.title, { color: isDark ? colors.gray[100] : colors.gray[900] }]}>
|
||||
Mehrere Dokumente erstellen
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose} disabled={isGenerating}>
|
||||
<Ionicons
|
||||
name="close-outline"
|
||||
size={24}
|
||||
color={isDark ? colors.gray[300] : colors.gray[700]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollView}>
|
||||
{/* Erklärung */}
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
Wie es funktioniert
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.explanation,
|
||||
{ color: isDark ? colors.gray[300] : colors.gray[700] },
|
||||
]}
|
||||
>
|
||||
Geben Sie einen Basis-Prompt ein und fügen Sie dann eine Liste von Subjekten
|
||||
hinzu, getrennt durch Kommas. Für jedes Subjekt wird ein eigenes Dokument
|
||||
erstellt, wobei der Basis-Prompt mit dem jeweiligen Subjekt kombiniert wird.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Basis-Prompt */}
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
Basis-Prompt
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[700] : colors.gray[100],
|
||||
color: isDark ? colors.gray[100] : colors.gray[900],
|
||||
borderColor: isDark ? colors.gray[600] : colors.gray[300],
|
||||
},
|
||||
]}
|
||||
value={basePrompt}
|
||||
onChangeText={setBasePrompt}
|
||||
placeholder="Basis-Prompt eingeben..."
|
||||
placeholderTextColor={isDark ? colors.gray[400] : colors.gray[500]}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
editable={!isGenerating}
|
||||
/>
|
||||
|
||||
{/* Prompt-Vorschläge für Basis-Prompt */}
|
||||
{promptDocuments.length > 0 && (
|
||||
<View style={styles.promptSuggestions}>
|
||||
<Text
|
||||
style={[
|
||||
styles.promptSuggestionsTitle,
|
||||
{ color: isDark ? colors.gray[300] : colors.gray[700] },
|
||||
]}
|
||||
>
|
||||
Gespeicherte Prompts:
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.promptsScrollView}
|
||||
>
|
||||
{promptDocuments.map((prompt) => (
|
||||
<TouchableOpacity
|
||||
key={`base-${prompt.id}`}
|
||||
style={[
|
||||
styles.promptSuggestion,
|
||||
{
|
||||
backgroundColor: isDark
|
||||
? 'rgba(217, 119, 6, 0.2)'
|
||||
: 'rgba(217, 119, 6, 0.1)',
|
||||
borderColor: '#d97706',
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
if (prompt.content) {
|
||||
setBasePrompt(prompt.content);
|
||||
Alert.alert(
|
||||
'Prompt eingefügt',
|
||||
'Der Prompt wurde als Basis-Prompt eingefügt.'
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.promptSuggestionTitle,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{prompt.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.promptSuggestionPreview,
|
||||
{ color: isDark ? colors.gray[300] : colors.gray[600] },
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{prompt.content
|
||||
? prompt.content.length > 50
|
||||
? prompt.content.substring(0, 50) + '...'
|
||||
: prompt.content
|
||||
: ''}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Subjekte */}
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
Subjekte (durch Kommas getrennt)
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[700] : colors.gray[100],
|
||||
color: isDark ? colors.gray[100] : colors.gray[900],
|
||||
borderColor: isDark ? colors.gray[600] : colors.gray[300],
|
||||
},
|
||||
]}
|
||||
value={subjects}
|
||||
onChangeText={setSubjects}
|
||||
placeholder="Subjekte eingeben..."
|
||||
placeholderTextColor={isDark ? colors.gray[400] : colors.gray[500]}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
editable={!isGenerating}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Prompt-Suffix */}
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
Prompt-Suffix
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[700] : colors.gray[100],
|
||||
color: isDark ? colors.gray[100] : colors.gray[900],
|
||||
borderColor: isDark ? colors.gray[600] : colors.gray[300],
|
||||
},
|
||||
]}
|
||||
placeholder="z.B. und beschreibe auch die wichtigsten Errungenschaften"
|
||||
placeholderTextColor={isDark ? colors.gray[400] : colors.gray[500]}
|
||||
value={promptSuffix}
|
||||
onChangeText={setPromptSuffix}
|
||||
multiline
|
||||
numberOfLines={2}
|
||||
editable={!isGenerating}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Dokumentauswahl */}
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
Dokumente als Kontext hinzufügen
|
||||
</Text>
|
||||
|
||||
{/* Dokumenttyp-Filter */}
|
||||
<View style={styles.filterContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterLabel,
|
||||
{ color: isDark ? colors.gray[300] : colors.gray[700] },
|
||||
]}
|
||||
>
|
||||
Filter:
|
||||
</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.filterOptions}>
|
||||
{[
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'context', label: 'Kontext' },
|
||||
{ value: 'prompt', label: 'Prompt' },
|
||||
].map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.filterOption,
|
||||
{
|
||||
backgroundColor:
|
||||
documentFilter === option.value
|
||||
? isDark
|
||||
? colors.primary[700]
|
||||
: colors.primary[100]
|
||||
: isDark
|
||||
? colors.gray[700]
|
||||
: colors.gray[100],
|
||||
borderColor:
|
||||
documentFilter === option.value
|
||||
? isDark
|
||||
? colors.primary[500]
|
||||
: colors.primary[500]
|
||||
: isDark
|
||||
? colors.gray[600]
|
||||
: colors.gray[300],
|
||||
},
|
||||
]}
|
||||
onPress={() => setDocumentFilter(option.value as any)}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterOptionText,
|
||||
{
|
||||
color:
|
||||
documentFilter === option.value
|
||||
? isDark
|
||||
? colors.primary[100]
|
||||
: colors.primary[800]
|
||||
: isDark
|
||||
? colors.gray[200]
|
||||
: colors.gray[800],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Dokumentliste */}
|
||||
<View style={styles.documentList}>
|
||||
{documents
|
||||
.filter((doc) => documentFilter === 'all' || doc.type === documentFilter)
|
||||
.map((doc) => (
|
||||
<TouchableOpacity
|
||||
key={doc.id}
|
||||
style={[
|
||||
styles.documentItem,
|
||||
{
|
||||
backgroundColor: selectedDocuments.includes(doc.id)
|
||||
? isDark
|
||||
? colors.primary[700]
|
||||
: colors.primary[100]
|
||||
: isDark
|
||||
? colors.gray[700]
|
||||
: colors.gray[100],
|
||||
borderColor: selectedDocuments.includes(doc.id)
|
||||
? isDark
|
||||
? colors.primary[500]
|
||||
: colors.primary[500]
|
||||
: isDark
|
||||
? colors.gray[600]
|
||||
: colors.gray[300],
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
// Normales Verhalten für alle Dokumente (nur Kontext-Dokumente werden angezeigt)
|
||||
if (doc.type !== 'prompt') {
|
||||
// Normales Verhalten für Kontext-Dokumente
|
||||
setSelectedDocuments((prev) =>
|
||||
prev.includes(doc.id)
|
||||
? prev.filter((id) => id !== doc.id)
|
||||
: [...prev, doc.id]
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<View style={styles.documentItemContent}>
|
||||
<Text
|
||||
style={[
|
||||
styles.documentTitle,
|
||||
{
|
||||
color: selectedDocuments.includes(doc.id)
|
||||
? isDark
|
||||
? colors.primary[100]
|
||||
: colors.primary[800]
|
||||
: isDark
|
||||
? colors.gray[200]
|
||||
: colors.gray[800],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{doc.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.documentType,
|
||||
{
|
||||
color: selectedDocuments.includes(doc.id)
|
||||
? isDark
|
||||
? colors.primary[200]
|
||||
: colors.primary[700]
|
||||
: isDark
|
||||
? colors.gray[300]
|
||||
: colors.gray[700],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{doc.type === 'text'
|
||||
? 'Text'
|
||||
: doc.type === 'context'
|
||||
? 'Kontext'
|
||||
: 'Prompt'}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={
|
||||
selectedDocuments.includes(doc.id)
|
||||
? 'checkmark-circle'
|
||||
: 'checkmark-circle-outline'
|
||||
}
|
||||
size={24}
|
||||
color={
|
||||
selectedDocuments.includes(doc.id)
|
||||
? isDark
|
||||
? colors.primary[300]
|
||||
: colors.primary[600]
|
||||
: isDark
|
||||
? colors.gray[400]
|
||||
: colors.gray[500]
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
{documents.filter(
|
||||
(doc) => documentFilter === 'all' || doc.type === documentFilter
|
||||
).length === 0 && (
|
||||
<Text
|
||||
style={[
|
||||
styles.noDocumentsText,
|
||||
{ color: isDark ? colors.gray[400] : colors.gray[500] },
|
||||
]}
|
||||
>
|
||||
Keine Dokumente vom Typ "
|
||||
{documentFilter === 'all'
|
||||
? 'Alle'
|
||||
: documentFilter === 'text'
|
||||
? 'Text'
|
||||
: documentFilter === 'context'
|
||||
? 'Kontext'
|
||||
: 'Prompt'}
|
||||
" gefunden.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* KI-Modell Auswahl */}
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: isDark ? colors.gray[200] : colors.gray[800] },
|
||||
]}
|
||||
>
|
||||
KI-Modell
|
||||
</Text>
|
||||
<View style={styles.modelSelector}>
|
||||
{availableModels.map((model) => (
|
||||
<TouchableOpacity
|
||||
key={model.value}
|
||||
style={[
|
||||
styles.modelOption,
|
||||
{
|
||||
backgroundColor:
|
||||
selectedModel === model.value
|
||||
? isDark
|
||||
? colors.primary[700]
|
||||
: colors.primary[100]
|
||||
: isDark
|
||||
? colors.gray[700]
|
||||
: colors.gray[100],
|
||||
borderColor:
|
||||
selectedModel === model.value
|
||||
? isDark
|
||||
? colors.primary[500]
|
||||
: colors.primary[500]
|
||||
: isDark
|
||||
? colors.gray[600]
|
||||
: colors.gray[300],
|
||||
},
|
||||
]}
|
||||
onPress={() => setSelectedModel(model.value)}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.modelOptionText,
|
||||
{
|
||||
color:
|
||||
selectedModel === model.value
|
||||
? isDark
|
||||
? colors.primary[100]
|
||||
: colors.primary[800]
|
||||
: isDark
|
||||
? colors.gray[200]
|
||||
: colors.gray[800],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{model.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Fehleranzeige */}
|
||||
{error && (
|
||||
<View
|
||||
style={[
|
||||
styles.errorContainer,
|
||||
{
|
||||
backgroundColor: isDark ? 'rgba(239, 68, 68, 0.2)' : 'rgba(239, 68, 68, 0.1)',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="alert-circle-outline" size={20} color="#ef4444" />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer mit Buttons */}
|
||||
<View
|
||||
style={[
|
||||
styles.footer,
|
||||
{ borderTopColor: isDark ? colors.gray[700] : colors.gray[200] },
|
||||
]}
|
||||
>
|
||||
<ThemedButton
|
||||
title="Abbrechen"
|
||||
variant="outline"
|
||||
onPress={onClose}
|
||||
disabled={isGenerating}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<ThemedButton
|
||||
title={isGenerating ? 'Wird erstellt...' : 'Dokumente erstellen'}
|
||||
variant="primary"
|
||||
onPress={handleCreateDocuments}
|
||||
disabled={isGenerating || !basePrompt.trim() || parseSubjects().length === 0}
|
||||
iconName="document-text-outline"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* LoadingScreen für die Batch-Verarbeitung */}
|
||||
<LoadingScreen
|
||||
visible={isGenerating}
|
||||
title="Dokumente werden erstellt"
|
||||
message="Die KI generiert Texte basierend auf Ihrem Prompt. Dies kann je nach Anzahl der Subjekte einige Zeit dauern."
|
||||
progress={{
|
||||
current: progress.current,
|
||||
total: progress.total,
|
||||
label:
|
||||
progress.current > 0
|
||||
? `Erstelle Dokument für: ${subjectList[progress.current - 1] || ''}`
|
||||
: '',
|
||||
}}
|
||||
icon={{
|
||||
name: 'document-text-outline',
|
||||
color: isDark ? colors.primary[400] : colors.primary[500],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
maxHeight: '90%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
scrollView: {
|
||||
padding: 16,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
explanation: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 8,
|
||||
},
|
||||
textInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
modelSelector: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
modelOption: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
modelOptionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
},
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
filterLabel: {
|
||||
fontSize: 14,
|
||||
marginRight: 8,
|
||||
},
|
||||
filterOptions: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
filterOption: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
marginRight: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
filterOptionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
documentList: {
|
||||
marginTop: 8,
|
||||
maxHeight: 200,
|
||||
},
|
||||
promptSuggestions: {
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
promptSuggestionsTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
promptsScrollView: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
promptSuggestion: {
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginRight: 8,
|
||||
width: 200,
|
||||
},
|
||||
promptSuggestionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
promptSuggestionPreview: {
|
||||
fontSize: 12,
|
||||
},
|
||||
documentItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
documentItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
documentTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
documentType: {
|
||||
fontSize: 12,
|
||||
},
|
||||
noDocumentsText: {
|
||||
textAlign: 'center',
|
||||
padding: 12,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Modal, View, StyleSheet, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { ThemedButton } from '~/components/ui/ThemedButton';
|
||||
import { deleteDocument } from '~/services/supabaseService';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface DeleteDocumentButtonProps {
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
onDelete: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DeleteDocumentButton: React.FC<DeleteDocumentButtonProps> = ({
|
||||
documentId,
|
||||
documentTitle,
|
||||
onDelete,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const { mode, colors } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const { success, error } = await deleteDocument(documentId);
|
||||
if (success) {
|
||||
setShowConfirmation(false);
|
||||
onDelete();
|
||||
} else {
|
||||
Alert.alert('Fehler', `Dokument konnte nicht gelöscht werden: ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Dokuments:', error);
|
||||
Alert.alert('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemedButton
|
||||
title="Löschen"
|
||||
iconName="trash-outline"
|
||||
variant="secondary"
|
||||
iconOnly={true}
|
||||
tooltip="Dokument löschen"
|
||||
onPress={() => setShowConfirmation(true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Modal visible={showConfirmation} transparent={true} animationType="fade">
|
||||
<View
|
||||
style={[
|
||||
styles.modalOverlay,
|
||||
{ backgroundColor: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.5)' },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[800] : colors.gray[50],
|
||||
borderColor: isDark ? colors.gray[700] : colors.gray[200],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modalHeader,
|
||||
{ borderBottomColor: isDark ? colors.gray[700] : colors.gray[200] },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.modalTitle, { color: isDark ? colors.gray[100] : colors.gray[900] }]}
|
||||
>
|
||||
Dokument löschen
|
||||
</Text>
|
||||
<ThemedButton
|
||||
title="Schließen"
|
||||
iconName="close-outline"
|
||||
variant="outline"
|
||||
size="small"
|
||||
iconOnly={true}
|
||||
onPress={() => setShowConfirmation(false)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalBody}>
|
||||
<Ionicons
|
||||
name="warning-outline"
|
||||
size={32}
|
||||
color={isDark ? '#f59e0b' : '#d97706'}
|
||||
style={styles.warningIcon}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={[styles.modalText, { color: isDark ? colors.gray[300] : colors.gray[700] }]}
|
||||
>
|
||||
Möchten Sie das Dokument "{documentTitle}" wirklich löschen? Diese Aktion kann nicht
|
||||
rückgängig gemacht werden.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<ThemedButton
|
||||
title="Abbrechen"
|
||||
variant="outline"
|
||||
onPress={() => setShowConfirmation(false)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
|
||||
<ThemedButton
|
||||
title={isDeleting ? 'Löschen...' : 'Löschen'}
|
||||
variant="danger"
|
||||
onPress={handleDelete}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalBody: {
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
warningIcon: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
modalText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
});
|
||||
268
apps/context/apps/mobile/components/documents/DocumentCard.tsx
Normal file
268
apps/context/apps/mobile/components/documents/DocumentCard.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Text } from '../ui/Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { DocumentCardDeleteButton } from './DocumentCardDeleteButton';
|
||||
import { extractTitleFromMarkdown } from '~/utils/markdown';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { toggleDocumentPinned } from '~/services/supabaseService';
|
||||
import { ThemedButton } from '../ui/ThemedButton';
|
||||
|
||||
type DocumentCardProps = {
|
||||
id: string;
|
||||
title?: string; // Now optional since we'll extract from content
|
||||
content?: string;
|
||||
type: 'original' | 'generated' | 'context' | 'prompt';
|
||||
created_at: string;
|
||||
onPress?: () => void;
|
||||
onDelete?: () => void;
|
||||
showDeleteButton?: boolean;
|
||||
pinned?: boolean;
|
||||
onPinToggle?: (pinned: boolean) => void;
|
||||
metadata?: any; // Für Tags und andere Metadaten
|
||||
};
|
||||
|
||||
export const DocumentCard = memo(
|
||||
({
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
created_at,
|
||||
onPress,
|
||||
onDelete,
|
||||
showDeleteButton = true,
|
||||
pinned = false,
|
||||
onPinToggle,
|
||||
metadata,
|
||||
}: DocumentCardProps) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// Memoized computed values
|
||||
const displayTitle = useMemo(() => {
|
||||
return content ? extractTitleFromMarkdown(content) : title || 'Unbenanntes Dokument';
|
||||
}, [content, title]);
|
||||
|
||||
const typeColors = useMemo(() => {
|
||||
const colors = {
|
||||
original: { color: '#2563eb', background: 'rgba(37, 99, 235, 0.1)', label: 'Original' },
|
||||
context: { color: '#16a34a', background: 'rgba(22, 163, 74, 0.1)', label: 'Kontext' },
|
||||
prompt: { color: '#d97706', background: 'rgba(217, 119, 6, 0.1)', label: 'Prompt' },
|
||||
generated: { color: '#0891b2', background: 'rgba(8, 145, 178, 0.1)', label: 'Generiert' },
|
||||
};
|
||||
return (
|
||||
colors[type] || {
|
||||
color: '#6b7280',
|
||||
background: 'rgba(107, 114, 128, 0.1)',
|
||||
label: 'Dokument',
|
||||
}
|
||||
);
|
||||
}, [type]);
|
||||
|
||||
const contentPreview = useMemo(() => {
|
||||
// Zeige die ersten 150 Zeichen als Vorschau
|
||||
if (!content) return null;
|
||||
const preview = content.length > 150 ? `${content.substring(0, 150)}...` : content;
|
||||
// Entferne Markdown-Syntax für bessere Lesbarkeit
|
||||
return preview.replace(/[#*_~`]/g, '');
|
||||
}, [content]);
|
||||
|
||||
const formattedDate = useMemo(() => {
|
||||
return new Date(created_at).toLocaleDateString();
|
||||
}, [created_at]);
|
||||
|
||||
// Funktion zum Umschalten des Pin-Status
|
||||
const handleTogglePin = useCallback(() => {
|
||||
const newPinnedState = !pinned;
|
||||
|
||||
try {
|
||||
// Aktualisiere den Pin-Status in der Datenbank
|
||||
toggleDocumentPinned(id, newPinnedState)
|
||||
.then(({ success, error }) => {
|
||||
if (success) {
|
||||
// Benachrichtige die übergeordnete Komponente über die Änderung
|
||||
if (onPinToggle) {
|
||||
onPinToggle(newPinnedState);
|
||||
}
|
||||
} else {
|
||||
console.error('Fehler beim Ändern des Pin-Status:', error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Fehler beim Ändern des Pin-Status:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Ändern des Pin-Status:', error);
|
||||
}
|
||||
}, [id, pinned, onPinToggle]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
padding: 12,
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
borderRadius: 0, // Eckige Borders
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
// Leichter Hintergrund für angepinnte Dokumente
|
||||
...(pinned && { backgroundColor: isDark ? '#1a2433' : '#f9fafb' }),
|
||||
}}
|
||||
onPress={onPress}
|
||||
>
|
||||
{/* Datum und Tags oben */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
}}
|
||||
>
|
||||
{formattedDate}
|
||||
</Text>
|
||||
|
||||
{/* Tags anzeigen, wenn vorhanden */}
|
||||
{metadata?.tags && metadata.tags.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
||||
{metadata.tags.slice(0, 2).map((tag: string, index: number) => (
|
||||
<Text
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 9999,
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
{metadata.tags.length > 2 && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 9999,
|
||||
}}
|
||||
>
|
||||
+{metadata.tags.length - 2}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
|
||||
{contentPreview && (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#9ca3af' : '#4b5563',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{contentPreview}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{/* Dokumenttyp */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: typeColors.color,
|
||||
backgroundColor: typeColors.background,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{typeColors.label}
|
||||
</Text>
|
||||
|
||||
{/* Pin-Button */}
|
||||
<View style={{ marginLeft: 8 }}>
|
||||
<ThemedButton
|
||||
title="Anpinnen"
|
||||
iconName={pinned ? 'pin' : 'pin-outline'}
|
||||
variant="secondary"
|
||||
iconOnly={true}
|
||||
size="small"
|
||||
isActive={pinned}
|
||||
tooltip={pinned ? 'Dokument lösen' : 'Dokument anpinnen'}
|
||||
onPress={handleTogglePin}
|
||||
style={
|
||||
pinned
|
||||
? {
|
||||
backgroundColor: isDark
|
||||
? 'rgba(249, 115, 22, 0.4)'
|
||||
: 'rgba(255, 237, 213, 0.4)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{showDeleteButton && (
|
||||
<View style={{ marginLeft: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
// Prevent the parent TouchableOpacity from being triggered
|
||||
e.stopPropagation();
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DocumentCardDeleteButton
|
||||
documentId={id}
|
||||
documentTitle={displayTitle}
|
||||
onDelete={onDelete ? onDelete : () => {}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Modal, View, StyleSheet, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { ThemedButton } from '~/components/ui/ThemedButton';
|
||||
import { deleteDocument } from '~/services/supabaseService';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface DocumentCardDeleteButtonProps {
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
onDelete: () => void;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
|
||||
export const DocumentCardDeleteButton: React.FC<DocumentCardDeleteButtonProps> = ({
|
||||
documentId,
|
||||
documentTitle,
|
||||
onDelete,
|
||||
stopPropagation = true,
|
||||
}) => {
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const { mode, colors } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
const handlePress = () => {
|
||||
setShowConfirmation(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const { success, error } = await deleteDocument(documentId);
|
||||
if (success) {
|
||||
setShowConfirmation(false);
|
||||
onDelete();
|
||||
} else {
|
||||
Alert.alert('Fehler', `Dokument konnte nicht gelöscht werden: ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Dokuments:', error);
|
||||
Alert.alert('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemedButton
|
||||
title="Löschen"
|
||||
iconName="trash-outline"
|
||||
variant="secondary"
|
||||
iconOnly={true}
|
||||
size="small"
|
||||
tooltip="Dokument löschen"
|
||||
onPress={handlePress}
|
||||
/>
|
||||
|
||||
<Modal visible={showConfirmation} transparent={true} animationType="fade">
|
||||
<View
|
||||
style={[
|
||||
styles.modalOverlay,
|
||||
{ backgroundColor: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.5)' },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[800] : colors.gray[50],
|
||||
borderColor: isDark ? colors.gray[700] : colors.gray[200],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modalHeader,
|
||||
{ borderBottomColor: isDark ? colors.gray[700] : colors.gray[200] },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.modalTitle, { color: isDark ? colors.gray[100] : colors.gray[900] }]}
|
||||
>
|
||||
Dokument löschen
|
||||
</Text>
|
||||
<ThemedButton
|
||||
title="Schließen"
|
||||
iconName="close-outline"
|
||||
variant="outline"
|
||||
size="small"
|
||||
iconOnly={true}
|
||||
onPress={() => setShowConfirmation(false)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalBody}>
|
||||
<Ionicons
|
||||
name="warning-outline"
|
||||
size={32}
|
||||
color={isDark ? '#f59e0b' : '#d97706'}
|
||||
style={styles.warningIcon}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={[styles.modalText, { color: isDark ? colors.gray[300] : colors.gray[700] }]}
|
||||
>
|
||||
Möchten Sie das Dokument "{documentTitle}" wirklich löschen? Diese Aktion kann nicht
|
||||
rückgängig gemacht werden.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<ThemedButton
|
||||
title="Abbrechen"
|
||||
variant="outline"
|
||||
onPress={() => setShowConfirmation(false)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
|
||||
<ThemedButton
|
||||
title={isDeleting ? 'Löschen...' : 'Löschen'}
|
||||
variant="danger"
|
||||
onPress={handleDelete}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalBody: {
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
warningIcon: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
modalText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { ThemedButton } from '~/components/ui/ThemedButton';
|
||||
import {
|
||||
Document,
|
||||
deleteDocument,
|
||||
updateDocument,
|
||||
toggleDocumentPinned,
|
||||
} from '~/services/supabaseService';
|
||||
import { DocumentTypeDropdown, DocumentType } from '~/components/documents/DocumentTypeDropdown';
|
||||
import { SpaceDropdown } from '~/components/spaces/SpaceDropdown';
|
||||
import { ConfirmationModal } from '~/components/ui/ConfirmationModal';
|
||||
|
||||
interface DocumentCardToolbarProps {
|
||||
document: Document;
|
||||
onDocumentUpdated?: (updatedDocument: Document) => void;
|
||||
onDocumentDeleted?: () => void;
|
||||
onDocumentPinned?: (pinned: boolean) => void;
|
||||
}
|
||||
|
||||
export const DocumentCardToolbar: React.FC<DocumentCardToolbarProps> = ({
|
||||
document,
|
||||
onDocumentUpdated,
|
||||
onDocumentDeleted,
|
||||
onDocumentPinned,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const router = useRouter();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isUpdatingType, setIsUpdatingType] = useState(false);
|
||||
const [isUpdatingSpace, setIsUpdatingSpace] = useState(false);
|
||||
const [isTogglingPin, setIsTogglingPin] = useState(false);
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
|
||||
// Funktion zum Öffnen des Lösch-Bestätigungsdialogs
|
||||
const handleDeleteDocument = () => {
|
||||
if (isDeleting) return;
|
||||
setShowDeleteConfirmation(true);
|
||||
};
|
||||
|
||||
const performDelete = async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const { success, error } = await deleteDocument(document.id);
|
||||
|
||||
if (!success) {
|
||||
console.error('Fehler beim Löschen des Dokuments:', error);
|
||||
alert(`Dokument konnte nicht gelöscht werden: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Callback aufrufen, wenn das Dokument erfolgreich gelöscht wurde
|
||||
if (onDocumentDeleted) {
|
||||
onDocumentDeleted();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Unerwarteter Fehler beim Löschen:', err);
|
||||
alert(`Unerwarteter Fehler: ${err.message}`);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Ändern des Dokumenttyps
|
||||
const handleTypeChange = async (newType: DocumentType) => {
|
||||
if (isUpdatingType) return;
|
||||
|
||||
try {
|
||||
setIsUpdatingType(true);
|
||||
|
||||
// Aktualisiere den Dokumenttyp in der Datenbank
|
||||
const { success, error } = await updateDocument(document.id, {
|
||||
type: newType,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
console.error('Fehler beim Aktualisieren des Dokumenttyps:', error);
|
||||
alert(`Dokumenttyp konnte nicht aktualisiert werden: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lokale Aktualisierung des Dokuments
|
||||
document.type = newType;
|
||||
|
||||
// Callback aufrufen, wenn das Dokument erfolgreich aktualisiert wurde
|
||||
if (onDocumentUpdated) {
|
||||
onDocumentUpdated(document);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Unerwarteter Fehler beim Aktualisieren des Typs:', err);
|
||||
alert(`Unerwarteter Fehler: ${err.message}`);
|
||||
} finally {
|
||||
setIsUpdatingType(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Ändern des Space
|
||||
const handleSpaceChange = async (newSpaceId: string) => {
|
||||
if (isUpdatingSpace) return;
|
||||
|
||||
try {
|
||||
setIsUpdatingSpace(true);
|
||||
|
||||
// Aktualisiere den Space in der Datenbank
|
||||
const { success, error } = await updateDocument(document.id, {
|
||||
space_id: newSpaceId,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
console.error('Fehler beim Aktualisieren des Space:', error);
|
||||
alert(`Space konnte nicht aktualisiert werden: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lokale Aktualisierung des Dokuments
|
||||
document.space_id = newSpaceId;
|
||||
|
||||
// Callback aufrufen, wenn das Dokument erfolgreich aktualisiert wurde
|
||||
if (onDocumentUpdated) {
|
||||
onDocumentUpdated(document);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Unerwarteter Fehler beim Aktualisieren des Space:', err);
|
||||
alert(`Unerwarteter Fehler: ${err.message}`);
|
||||
} finally {
|
||||
setIsUpdatingSpace(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: isDark ? 'rgba(31, 41, 55, 0.95)' : 'rgba(249, 250, 251, 0.95)' },
|
||||
]}
|
||||
>
|
||||
<View style={styles.toolbarContent}>
|
||||
{/* Rechts ausgerichtete Buttons */}
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
<View style={styles.buttonsContainer}>
|
||||
{/* Space-Dropdown */}
|
||||
<SpaceDropdown
|
||||
currentSpaceId={document.space_id}
|
||||
onSpaceChange={handleSpaceChange}
|
||||
disabled={isUpdatingSpace}
|
||||
openUpwards={true}
|
||||
/>
|
||||
|
||||
{/* Dokumenttyp-Dropdown */}
|
||||
<DocumentTypeDropdown
|
||||
currentType={document.type as DocumentType}
|
||||
onTypeChange={handleTypeChange}
|
||||
disabled={isUpdatingType}
|
||||
openUpwards={true}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
|
||||
{/* Pin-Button */}
|
||||
<ThemedButton
|
||||
title="Anpinnen"
|
||||
onPress={async () => {
|
||||
if (isTogglingPin) return;
|
||||
|
||||
try {
|
||||
setIsTogglingPin(true);
|
||||
const newPinnedState = !(document.pinned || false);
|
||||
|
||||
const { success, error } = await toggleDocumentPinned(document.id, newPinnedState);
|
||||
|
||||
if (success) {
|
||||
// Lokale Aktualisierung des Dokuments
|
||||
document.pinned = newPinnedState;
|
||||
|
||||
// Callback aufrufen, wenn das Dokument erfolgreich aktualisiert wurde
|
||||
if (onDocumentPinned) {
|
||||
onDocumentPinned(newPinnedState);
|
||||
}
|
||||
|
||||
if (onDocumentUpdated) {
|
||||
onDocumentUpdated(document);
|
||||
}
|
||||
} else {
|
||||
console.error('Fehler beim Ändern des Pin-Status:', error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unerwarteter Fehler beim Ändern des Pin-Status:', err);
|
||||
} finally {
|
||||
setIsTogglingPin(false);
|
||||
}
|
||||
}}
|
||||
variant="secondary"
|
||||
iconName={document.pinned || false ? 'pin' : 'pin-outline'}
|
||||
iconOnly={true}
|
||||
disabled={isTogglingPin}
|
||||
tooltip={document.pinned || false ? 'Dokument lösen' : 'Dokument anpinnen'}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
backgroundColor:
|
||||
document.pinned || false
|
||||
? isDark
|
||||
? 'rgba(249, 115, 22, 0.4)'
|
||||
: 'rgba(255, 237, 213, 0.4)'
|
||||
: undefined,
|
||||
}}
|
||||
isActive={document.pinned || false}
|
||||
/>
|
||||
|
||||
{/* Löschen-Button */}
|
||||
<ThemedButton
|
||||
title="Dokument löschen"
|
||||
onPress={handleDeleteDocument}
|
||||
variant="secondary"
|
||||
iconName="trash-outline"
|
||||
iconOnly={true}
|
||||
disabled={isDeleting}
|
||||
tooltip="Dokument löschen"
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Lösch-Bestätigungsdialog */}
|
||||
<ConfirmationModal
|
||||
visible={showDeleteConfirmation}
|
||||
title="Dokument löschen"
|
||||
message="Möchten Sie dieses Dokument wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
confirmText="Löschen"
|
||||
cancelText="Abbrechen"
|
||||
onConfirm={async () => {
|
||||
await performDelete();
|
||||
setShowDeleteConfirmation(false);
|
||||
}}
|
||||
onCancel={() => setShowDeleteConfirmation(false)}
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 8,
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
zIndex: 10,
|
||||
},
|
||||
toolbarContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { View, TextInput, ScrollView, Platform, useWindowDimensions } from 'react-native';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { MentionTextInput } from '~/components/mentions/MentionTextInput';
|
||||
import { DocumentMode } from '~/types/documentEditor';
|
||||
import { EDITOR_CONFIG } from '~/config/editorConfig';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
export interface DocumentContentProps {
|
||||
mode: DocumentMode;
|
||||
content: string;
|
||||
onContentChange: (content: string) => void;
|
||||
isNewDocument: boolean;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponente für den Dokumentinhalt - Edit und Preview Mode
|
||||
* Extrahiert aus dem ursprünglichen DocumentEditor
|
||||
*/
|
||||
export const DocumentContent: React.FC<DocumentContentProps> = ({
|
||||
mode,
|
||||
content,
|
||||
onContentChange,
|
||||
isNewDocument,
|
||||
autoFocus = false,
|
||||
className,
|
||||
spaceId,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const isDesktop = width > 1024;
|
||||
const textInputRef = useRef<TextInput>(null);
|
||||
|
||||
// Auto-Focus für neue Dokumente
|
||||
useEffect(() => {
|
||||
if (autoFocus && mode === 'edit' && isNewDocument) {
|
||||
// Slight delay to ensure component is fully rendered
|
||||
const timer = setTimeout(() => {
|
||||
textInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [autoFocus, mode, isNewDocument]);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(text: string) => {
|
||||
onContentChange(text);
|
||||
},
|
||||
[onContentChange]
|
||||
);
|
||||
|
||||
// Markdown-Styles für Preview
|
||||
const markdownStyles = {
|
||||
body: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
fontFamily: Platform.OS === 'ios' ? 'system' : 'sans-serif',
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 24,
|
||||
marginBottom: 16,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 20,
|
||||
marginBottom: 12,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: 16,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
list_item: {
|
||||
marginBottom: 8,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 4,
|
||||
paddingLeft: 16,
|
||||
borderLeftColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
marginVertical: 16,
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
backgroundColor: 'transparent',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
link: {
|
||||
color: isDark ? '#93c5fd' : '#3b82f6',
|
||||
textDecorationLine: 'underline' as const,
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
padding: 4,
|
||||
borderRadius: 4,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginVertical: 16,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
},
|
||||
table: {
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
borderRadius: 8,
|
||||
marginVertical: 16,
|
||||
},
|
||||
thead: {
|
||||
backgroundColor: isDark ? '#374151' : '#f9fafb',
|
||||
},
|
||||
tbody: {
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
},
|
||||
th: {
|
||||
fontWeight: 'bold',
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
td: {
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? '#374151' : '#f3f4f6',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
};
|
||||
|
||||
if (mode === 'edit') {
|
||||
return (
|
||||
<View
|
||||
className={className}
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: isDesktop ? 800 : '100%',
|
||||
width: '100%',
|
||||
marginHorizontal: 'auto',
|
||||
}}
|
||||
>
|
||||
<MentionTextInput
|
||||
ref={textInputRef}
|
||||
spaceId={spaceId}
|
||||
value={content}
|
||||
onChangeText={handleContentChange}
|
||||
placeholder={
|
||||
isNewDocument ? 'Beginne mit dem Schreiben...' : 'Dokumentinhalt bearbeiten...'
|
||||
}
|
||||
placeholderTextColor={isDark ? '#9ca3af' : '#6b7280'}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
fontFamily: Platform.OS === 'ios' ? 'system' : 'sans-serif',
|
||||
paddingTop: EDITOR_CONFIG.PREVIEW_PADDING.TOP,
|
||||
paddingBottom: EDITOR_CONFIG.PREVIEW_PADDING.BOTTOM,
|
||||
paddingHorizontal: 16,
|
||||
textAlignVertical: 'top',
|
||||
}}
|
||||
multiline
|
||||
scrollEnabled={false} // ScrollView handles this
|
||||
autoFocus={autoFocus && isNewDocument}
|
||||
// Accessibility
|
||||
accessibilityLabel="Dokumentinhalt bearbeiten"
|
||||
accessibilityHint="Hier können Sie Ihren Dokumentinhalt eingeben und bearbeiten"
|
||||
accessibilityRole="text"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Preview Mode
|
||||
return (
|
||||
<View
|
||||
className={className}
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: isDesktop ? 800 : '100%',
|
||||
width: '100%',
|
||||
marginHorizontal: 'auto',
|
||||
}}
|
||||
>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: EDITOR_CONFIG.PREVIEW_PADDING.TOP,
|
||||
paddingBottom: EDITOR_CONFIG.PREVIEW_PADDING.BOTTOM,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{content ? (
|
||||
<Markdown style={markdownStyles}>{content}</Markdown>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 100,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{isNewDocument
|
||||
? 'Beginne mit dem Schreiben im Edit-Modus'
|
||||
: 'Dieses Dokument ist leer'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
320
apps/context/apps/mobile/components/documents/DocumentEditor.tsx
Normal file
320
apps/context/apps/mobile/components/documents/DocumentEditor.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { View, SafeAreaView, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { useDocumentEditor } from '~/hooks/useDocumentEditor';
|
||||
import { DocumentContent } from './DocumentContent';
|
||||
import { DocumentToolbar, KeyboardShortcutsInfo } from './DocumentToolbar';
|
||||
import { DocumentTagsEditor } from './DocumentTagsEditor';
|
||||
import { DocumentHeader } from './DocumentHeader';
|
||||
import { VariantCreator } from '~/components/variants/VariantCreator';
|
||||
import { BottomLLMToolbar } from '~/components/ai/BottomLLMToolbar';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { Skeleton } from '~/components/ui/Skeleton';
|
||||
import { EDITOR_CONFIG } from '~/config/editorConfig';
|
||||
|
||||
export interface DocumentEditorProps {
|
||||
spaceId: string;
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimierter Dokumenten-Editor mit separaten Komponenten und Custom Hooks
|
||||
* Ersetzt die ursprüngliche 1.322-Zeilen-Komponente
|
||||
*/
|
||||
export const DocumentEditor: React.FC<DocumentEditorProps> = ({ spaceId, documentId }) => {
|
||||
const { isDark } = useTheme();
|
||||
const params = useLocalSearchParams();
|
||||
const initialMode = (params.mode as 'edit' | 'preview') || 'edit';
|
||||
|
||||
const {
|
||||
state,
|
||||
dispatch,
|
||||
saveDocument,
|
||||
toggleMode,
|
||||
updateContent,
|
||||
updateTitle,
|
||||
updateTags,
|
||||
autoSave,
|
||||
navigateToNextDocument,
|
||||
navigateToSpace,
|
||||
isNewDocument,
|
||||
canSave,
|
||||
} = useDocumentEditor({
|
||||
spaceId,
|
||||
documentId,
|
||||
initialMode,
|
||||
});
|
||||
|
||||
// Keyboard Shortcuts (nur für Web)
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'web') return;
|
||||
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case 's':
|
||||
e.preventDefault();
|
||||
if (canSave) {
|
||||
saveDocument();
|
||||
}
|
||||
break;
|
||||
case 'p':
|
||||
e.preventDefault();
|
||||
toggleMode();
|
||||
break;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
// Focus auf Content-Eingabe setzen
|
||||
dispatch({ type: 'SET_MODE', payload: 'edit' });
|
||||
break;
|
||||
case 'n':
|
||||
e.preventDefault();
|
||||
navigateToSpace();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
return () => document.removeEventListener('keydown', handleKeyPress);
|
||||
}, [canSave, saveDocument, toggleMode, dispatch, navigateToSpace]);
|
||||
|
||||
// Handlers
|
||||
const handleToggleMode = useCallback(() => {
|
||||
toggleMode();
|
||||
}, [toggleMode]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
saveDocument();
|
||||
}, [saveDocument]);
|
||||
|
||||
const handleShowTags = useCallback(() => {
|
||||
dispatch({ type: 'SET_SHOW_TAGS_EDITOR', payload: !state.showTagsEditor });
|
||||
}, [dispatch, state.showTagsEditor]);
|
||||
|
||||
const handleShowVariantCreator = useCallback(() => {
|
||||
dispatch({ type: 'SET_SHOW_VARIANT_CREATOR', payload: !state.showVariantCreator });
|
||||
}, [dispatch, state.showVariantCreator]);
|
||||
|
||||
const handleTagsUpdate = useCallback(
|
||||
(tags: string[]) => {
|
||||
updateTags(tags);
|
||||
},
|
||||
[updateTags]
|
||||
);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(content: string) => {
|
||||
updateContent(content);
|
||||
},
|
||||
[updateContent]
|
||||
);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(title: string) => {
|
||||
updateTitle(title);
|
||||
},
|
||||
[updateTitle]
|
||||
);
|
||||
|
||||
const handleGenerateText = useCallback(
|
||||
(text: string) => {
|
||||
// Füge generierten Text am Ende des aktuellen Inhalts hinzu
|
||||
const newContent = state.content + '\\n\\n---\\n\\n' + text;
|
||||
updateContent(newContent);
|
||||
},
|
||||
[state.content, updateContent]
|
||||
);
|
||||
|
||||
const handleCreateVariant = useCallback(
|
||||
(variant: any) => {
|
||||
// Hier würde die Varianten-Erstellung implementiert
|
||||
console.log('Create variant:', variant);
|
||||
dispatch({ type: 'SET_SHOW_VARIANT_CREATOR', payload: false });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Loading Screen
|
||||
if (state.loading) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: isDark ? '#111827' : '#f9fafb' }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Lädt...',
|
||||
headerStyle: { backgroundColor: isDark ? '#1f2937' : '#ffffff' },
|
||||
headerTintColor: isDark ? '#f9fafb' : '#1f2937',
|
||||
}}
|
||||
/>
|
||||
<View style={{ flex: 1, padding: 16 }}>
|
||||
<Skeleton width="100%" height={32} style={{ marginBottom: 16 }} />
|
||||
<Skeleton width="80%" height={20} style={{ marginBottom: 24 }} />
|
||||
<Skeleton width="100%" height={200} style={{ marginBottom: 16 }} />
|
||||
<Skeleton width="60%" height={20} style={{ marginBottom: 16 }} />
|
||||
<Skeleton width="90%" height={20} style={{ marginBottom: 16 }} />
|
||||
<Skeleton width="75%" height={20} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// Error Screen
|
||||
if (state.error) {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: isDark ? '#111827' : '#f9fafb' }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Fehler',
|
||||
headerStyle: { backgroundColor: isDark ? '#1f2937' : '#ffffff' },
|
||||
headerTintColor: isDark ? '#f9fafb' : '#1f2937',
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: isDark ? '#ef4444' : '#dc2626',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{state.error}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Bitte versuche es später erneut oder kontaktiere den Support.
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: isDark ? '#111827' : '#f9fafb' }}>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: state.document?.title || 'Neues Dokument',
|
||||
headerStyle: { backgroundColor: isDark ? '#1f2937' : '#ffffff' },
|
||||
headerTintColor: isDark ? '#f9fafb' : '#1f2937',
|
||||
headerBackVisible: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Keyboard Shortcuts Info (nur Web) */}
|
||||
<KeyboardShortcutsInfo />
|
||||
|
||||
{/* Document Header */}
|
||||
<DocumentHeader
|
||||
title={state.title}
|
||||
onTitleChange={handleTitleChange}
|
||||
spaceName={state.spaceName}
|
||||
isNewDocument={isNewDocument}
|
||||
onNavigateToSpace={navigateToSpace}
|
||||
onNavigateToNext={state.nextDocument ? navigateToNextDocument : undefined}
|
||||
nextDocumentTitle={state.nextDocument?.title}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Tags Editor (wenn sichtbar) */}
|
||||
{state.showTagsEditor && (
|
||||
<DocumentTagsEditor
|
||||
documentId={documentId}
|
||||
tags={state.tags}
|
||||
onTagsChange={handleTagsUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<DocumentContent
|
||||
mode={state.mode}
|
||||
content={state.content}
|
||||
onContentChange={handleContentChange}
|
||||
isNewDocument={isNewDocument}
|
||||
autoFocus={isNewDocument && state.mode === 'edit'}
|
||||
className="flex-1"
|
||||
spaceId={spaceId}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Bottom Toolbar */}
|
||||
<DocumentToolbar
|
||||
mode={state.mode}
|
||||
onToggleMode={handleToggleMode}
|
||||
onSave={handleSave}
|
||||
onShowTags={handleShowTags}
|
||||
onShowVariantCreator={handleShowVariantCreator}
|
||||
saveStatus={autoSave.saveState}
|
||||
lastSaved={autoSave.lastSaved}
|
||||
saveError={autoSave.error?.message}
|
||||
canSave={canSave}
|
||||
isGeneratingText={state.isGeneratingText}
|
||||
showTagsEditor={state.showTagsEditor}
|
||||
/>
|
||||
|
||||
{/* AI Toolbar (nur im Edit-Modus) */}
|
||||
{state.mode === 'edit' && (
|
||||
<BottomLLMToolbar
|
||||
documentContent={state.content}
|
||||
onGenerateText={(text, mode) => {
|
||||
// Handle the generated text based on mode
|
||||
if (mode === 'replace') {
|
||||
updateContent(text);
|
||||
} else {
|
||||
updateContent(state.content + text);
|
||||
}
|
||||
}}
|
||||
isGenerating={state.isGeneratingText}
|
||||
setIsGenerating={(isGenerating) => {
|
||||
dispatch({ type: 'SET_IS_GENERATING_TEXT', payload: isGenerating });
|
||||
}}
|
||||
documentId={documentId}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Variant Creator Modal */}
|
||||
{state.showVariantCreator && (
|
||||
<VariantCreator
|
||||
visible={state.showVariantCreator}
|
||||
onClose={() => dispatch({ type: 'SET_SHOW_VARIANT_CREATOR', payload: false })}
|
||||
documentContent={state.content}
|
||||
documentTitle={state.title}
|
||||
documentId={documentId}
|
||||
spaceId={spaceId}
|
||||
onVariantCreated={(newDocumentId) => {
|
||||
console.log('Variant created:', newDocumentId);
|
||||
dispatch({ type: 'SET_SHOW_VARIANT_CREATOR', payload: false });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentEditor;
|
||||
|
|
@ -0,0 +1,672 @@
|
|||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { View, ScrollView, useWindowDimensions, Pressable, Platform, FlatList } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { Skeleton } from '~/components/ui/Skeleton';
|
||||
import { Document } from '~/services/supabaseService';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
import { DocumentTypeBadge } from './DocumentTypeBadge';
|
||||
import { DocumentCardToolbar } from './DocumentCardToolbar';
|
||||
|
||||
type DocumentGalleryProps = {
|
||||
documents: Document[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
searchQuery?: string;
|
||||
selectedSpaceIds?: string[];
|
||||
onCreateDocument?: () => void;
|
||||
};
|
||||
|
||||
export const DocumentGallery = ({
|
||||
documents,
|
||||
loading = false,
|
||||
error = null,
|
||||
searchQuery = '',
|
||||
selectedSpaceIds = [],
|
||||
onCreateDocument,
|
||||
}: DocumentGalleryProps) => {
|
||||
const router = useRouter();
|
||||
const { isDark } = useTheme();
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
// State für Hover und Pressed für den "Neues Dokument"-Button
|
||||
const [newDocHovered, setNewDocHovered] = useState(false);
|
||||
const [newDocPressed, setNewDocPressed] = useState(false);
|
||||
|
||||
// State für Hover-Effekte der Dokumente
|
||||
const [hoveredDocId, setHoveredDocId] = useState<string | null>(null);
|
||||
|
||||
// State für die Dokumente
|
||||
const [documentsList, setDocumentsList] = useState<Document[]>(documents);
|
||||
|
||||
// Markdown-Styles für die Karten-Vorschau
|
||||
const markdownStyles = {
|
||||
body: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
fontFamily: Platform.OS === 'ios' ? 'system' : 'sans-serif',
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold' as const,
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold' as const,
|
||||
marginTop: 6,
|
||||
marginBottom: 6,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 15,
|
||||
fontWeight: 'bold' as const,
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: 8,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
color: isDark ? '#f9fafb' : '#1f2937',
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
fontSize: 13,
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: isDark ? '#1f2937' : '#f9fafb',
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
marginVertical: 8,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
fontSize: 13,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
list_item: {
|
||||
marginBottom: 4,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
paddingLeft: 8,
|
||||
borderLeftColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
marginVertical: 8,
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
fontStyle: 'italic' as const,
|
||||
},
|
||||
link: {
|
||||
color: isDark ? '#93c5fd' : '#3b82f6',
|
||||
textDecorationLine: 'underline' as const,
|
||||
},
|
||||
strong: {
|
||||
fontWeight: 'bold' as const,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
em: {
|
||||
fontStyle: 'italic' as const,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
};
|
||||
|
||||
// Funktion zum Neuladen der Daten
|
||||
const loadData = useCallback(() => {
|
||||
// Wenn eine onCreateDocument-Funktion übergeben wurde, nutze diese zum Neuladen
|
||||
if (onCreateDocument) {
|
||||
// Wir können die Funktion nicht direkt aufrufen, da sie zur Dokumenterstellung dient
|
||||
// Stattdessen informieren wir die übergeordnete Komponente, dass ein Reload nötig ist
|
||||
// Die übergeordnete Komponente muss dann die Dokumente neu laden
|
||||
onCreateDocument();
|
||||
}
|
||||
}, [onCreateDocument]);
|
||||
|
||||
// Aktualisiere die Dokumentenliste, wenn sich die Props ändern
|
||||
// Optimiert: Die Dokumente sind bereits sortiert, daher keine erneute Sortierung nötig
|
||||
useEffect(() => {
|
||||
if (documents && Array.isArray(documents)) {
|
||||
setDocumentsList(documents);
|
||||
}
|
||||
}, [documents]);
|
||||
|
||||
// Dynamische Kartengrößen basierend auf der Bildschirmbreite
|
||||
// Stellt sicher, dass ungefähr 1,5 Karten sichtbar sind
|
||||
const cardWidth = Math.min(Math.max(240, width * 0.65), 500); // Mindestens 240px, maximal 500px
|
||||
const cardHeight = Math.min(Math.max(340, height * 0.8), 650); // Mindestens 340px, maximal 650px oder 80% der Bildschirmhöhe
|
||||
|
||||
// "Neues Dokument"-Karte ist halb so breit oder doppelt so breit, wenn keine Dokumente vorhanden sind
|
||||
const newDocCardWidth =
|
||||
documents.length > 0
|
||||
? Math.min(cardWidth * 0.5, 250) // Maximal 250px breit, wenn Dokumente vorhanden sind
|
||||
: Math.min(cardWidth, 500); // Doppelt so breit, wenn keine Dokumente vorhanden sind
|
||||
|
||||
// Funktionen für den "Neues Dokument"-Button
|
||||
const getNewDocBackgroundColor = () => {
|
||||
if (newDocPressed) {
|
||||
return isDark ? '#4b5563' : '#d1d5db';
|
||||
}
|
||||
if (newDocHovered) {
|
||||
return isDark ? '#374151' : '#e5e7eb';
|
||||
}
|
||||
return isDark ? '#1f2937' : '#ffffff';
|
||||
};
|
||||
|
||||
const getNewDocTextColor = () => {
|
||||
if (newDocPressed) {
|
||||
return isDark ? '#f9fafb' : '#111827';
|
||||
}
|
||||
if (newDocHovered) {
|
||||
return isDark ? '#f3f4f6' : '#1f2937';
|
||||
}
|
||||
return isDark ? '#f3f4f6' : '#111827';
|
||||
};
|
||||
|
||||
const getNewDocIconColor = () => {
|
||||
if (newDocPressed) {
|
||||
return isDark ? '#e5e7eb' : '#4b5563';
|
||||
}
|
||||
if (newDocHovered) {
|
||||
return isDark ? '#d1d5db' : '#6b7280';
|
||||
}
|
||||
return isDark ? '#9ca3af' : '#6b7280';
|
||||
};
|
||||
|
||||
const getNewDocBorderColor = () => {
|
||||
if (newDocPressed) {
|
||||
return isDark ? '#6b7280' : '#9ca3af';
|
||||
}
|
||||
if (newDocHovered) {
|
||||
return isDark ? '#4b5563' : '#d1d5db';
|
||||
}
|
||||
return isDark ? '#374151' : '#e5e7eb';
|
||||
};
|
||||
|
||||
// Funktionen für die Dokumente
|
||||
const getDocBackgroundColor = (docId: string) => {
|
||||
if (hoveredDocId === docId) {
|
||||
return isDark ? '#263548' : '#f9fafb';
|
||||
}
|
||||
return isDark ? '#1f2937' : '#ffffff';
|
||||
};
|
||||
|
||||
const getDocBorderColor = (docId: string) => {
|
||||
if (hoveredDocId === docId) {
|
||||
return isDark ? '#4b5563' : '#d1d5db';
|
||||
}
|
||||
return isDark ? '#374151' : '#e5e7eb';
|
||||
};
|
||||
|
||||
// Kombiniere "Neues Dokument" Item mit den Dokumenten für FlatList
|
||||
const flatListData = useMemo(() => {
|
||||
const items: { type: 'new' | 'document'; data?: Document }[] = [];
|
||||
|
||||
// Sicherheitscheck für documentsList
|
||||
if (!documentsList || !Array.isArray(documentsList)) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (onCreateDocument && documentsList.length > 0) {
|
||||
items.push({ type: 'new' });
|
||||
}
|
||||
|
||||
documentsList.forEach((doc) => {
|
||||
if (doc && doc.id) {
|
||||
items.push({ type: 'document', data: doc });
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [documentsList, onCreateDocument]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: any; index: number }) => {
|
||||
// Sicherheitscheck
|
||||
if (!item || !item.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (item.type === 'new') {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginRight: 16,
|
||||
width: newDocCardWidth,
|
||||
height: cardHeight,
|
||||
borderRadius: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
backgroundColor: getNewDocBackgroundColor(),
|
||||
borderWidth: 1,
|
||||
borderColor: getNewDocBorderColor(),
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 4,
|
||||
},
|
||||
]}
|
||||
onPress={onCreateDocument}
|
||||
onHoverIn={() => Platform.OS === 'web' && setNewDocHovered(true)}
|
||||
onHoverOut={() => Platform.OS === 'web' && setNewDocHovered(false)}
|
||||
>
|
||||
<Ionicons
|
||||
name="add"
|
||||
size={48}
|
||||
color={getNewDocIconColor()}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: getNewDocTextColor(),
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Neues Dokument
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const doc = item.data;
|
||||
if (!doc) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={doc.id}
|
||||
style={{
|
||||
marginRight: 16,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
borderRadius: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
{
|
||||
flex: 1,
|
||||
padding: 0,
|
||||
position: 'relative',
|
||||
backgroundColor: getDocBackgroundColor(doc.id),
|
||||
borderWidth: 1,
|
||||
borderColor: getDocBorderColor(doc.id),
|
||||
borderRadius: 4,
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/spaces/${doc.space_id}/documents/${doc.id}?mode=edit`)}
|
||||
onHoverIn={() => Platform.OS === 'web' && setHoveredDocId(doc.id)}
|
||||
onHoverOut={() => Platform.OS === 'web' && setHoveredDocId(null)}
|
||||
>
|
||||
{/* Document content - render lazily */}
|
||||
<ScrollView
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
paddingBottom: 60, // Platz für die Toolbar
|
||||
}}
|
||||
>
|
||||
{/* Datum und Tags */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
}}
|
||||
>
|
||||
{new Date(doc.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
|
||||
{/* Tags anzeigen */}
|
||||
{doc.metadata?.tags && doc.metadata.tags.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
||||
{doc.metadata.tags.slice(0, 2).map((tag: string, index: number) => (
|
||||
<Text
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 9999,
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
{doc.metadata.tags.length > 2 && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 9999,
|
||||
}}
|
||||
>
|
||||
+{doc.metadata.tags.length - 2}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Dokumenttitel */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: isDark ? '#f3f4f6' : '#111827',
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{doc.title || 'Unbenanntes Dokument'}
|
||||
</Text>
|
||||
|
||||
{/* Dokumentinhalt mit Markdown-Rendering */}
|
||||
{doc.content ? (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Markdown style={markdownStyles} mergeStyle>
|
||||
{doc.content}
|
||||
</Markdown>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
Leeres Dokument
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Dokument-Toolbar */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: isDark ? '#374151' : '#e5e7eb',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<DocumentTypeBadge type={doc.type} />
|
||||
<DocumentCardToolbar
|
||||
document={doc}
|
||||
onDocumentUpdated={(updatedDocument) => {
|
||||
// Update local state
|
||||
const updatedDocs = documentsList.map((d) =>
|
||||
d.id === doc.id ? updatedDocument : d
|
||||
);
|
||||
setDocumentsList(updatedDocs);
|
||||
}}
|
||||
onDocumentDeleted={() => {
|
||||
// Remove from local state
|
||||
const updatedDocs = documentsList.filter((d) => d.id !== doc.id);
|
||||
setDocumentsList(updatedDocs);
|
||||
loadData();
|
||||
}}
|
||||
onDocumentPinned={(pinned) => {
|
||||
// Update local state
|
||||
const updatedDocs = documentsList.map((d) =>
|
||||
d.id === doc.id ? { ...d, pinned } : d
|
||||
);
|
||||
setDocumentsList(updatedDocs);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
newDocCardWidth,
|
||||
router,
|
||||
onCreateDocument,
|
||||
getNewDocBackgroundColor,
|
||||
getNewDocBorderColor,
|
||||
getNewDocIconColor,
|
||||
getNewDocTextColor,
|
||||
getDocBackgroundColor,
|
||||
getDocBorderColor,
|
||||
hoveredDocId,
|
||||
isDark,
|
||||
documentsList,
|
||||
loadData,
|
||||
]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<FlatList
|
||||
horizontal
|
||||
data={Array.from({ length: 3 })} // Skeleton für 3 Dokumente
|
||||
renderItem={({ index }) => (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
marginRight: 16,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
borderRadius: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Skeleton width="100%" height={20} style={{ marginBottom: 8 }} />
|
||||
<Skeleton width="80%" height={16} style={{ marginBottom: 8 }} />
|
||||
<Skeleton width="60%" height={16} style={{ marginBottom: 16 }} />
|
||||
<Skeleton width="100%" height={200} />
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(_, index) => `skeleton-${index}`}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingLeft: 16, paddingRight: 32 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: 16,
|
||||
backgroundColor: isDark ? '#7f1d1d' : '#fee2e2',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: isDark ? '#fecaca' : '#991b1b' }}>{error}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Wenn keine Dokumente vorhanden sind, verwenden wir ein anderes Layout
|
||||
if (documents.length === 0 && onCreateDocument) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
>
|
||||
{/* Zentriertes "Neues Dokument"-Element mit Hover-State */}
|
||||
<View
|
||||
style={{
|
||||
width: newDocCardWidth,
|
||||
height: cardHeight,
|
||||
borderRadius: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
backgroundColor: getNewDocBackgroundColor(),
|
||||
borderWidth: 1,
|
||||
borderColor: getNewDocBorderColor(),
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 4,
|
||||
},
|
||||
]}
|
||||
onPress={onCreateDocument}
|
||||
onHoverIn={() => Platform.OS === 'web' && setNewDocHovered(true)}
|
||||
onHoverOut={() => Platform.OS === 'web' && setNewDocHovered(false)}
|
||||
onPressIn={() => setNewDocPressed(true)}
|
||||
onPressOut={() => setNewDocPressed(false)}
|
||||
>
|
||||
<Ionicons name="add" size={64} color={getNewDocIconColor()} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: getNewDocTextColor(),
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Neues Dokument
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* "Keine Dokumente"-Meldung entfernt */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Normales Layout für vorhandene Dokumente
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
horizontal
|
||||
data={flatListData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) =>
|
||||
item.type === 'new' ? 'new-doc' : item.data?.id || `doc-${index}`
|
||||
}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: 16,
|
||||
paddingRight: 32,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
marginTop: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
// Optimierungen für bessere Performance
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={5}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={Platform.OS !== 'web'}
|
||||
getItemLayout={(data, index) => {
|
||||
// Sicherheitscheck
|
||||
if (!data || !Array.isArray(data) || index < 0 || index >= data.length) {
|
||||
return {
|
||||
length: cardWidth + 16,
|
||||
offset: (cardWidth + 16) * index,
|
||||
index,
|
||||
};
|
||||
}
|
||||
|
||||
// Berechne die Breite basierend auf dem Item-Typ
|
||||
const itemWidth = data[index]?.type === 'new' ? newDocCardWidth : cardWidth;
|
||||
const margin = 16;
|
||||
|
||||
// Berechne das Offset basierend auf den vorherigen Items
|
||||
let offset = 16; // Anfangs-Padding
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prevItemWidth = data[i]?.type === 'new' ? newDocCardWidth : cardWidth;
|
||||
offset += prevItemWidth + margin;
|
||||
}
|
||||
|
||||
return {
|
||||
length: itemWidth + margin,
|
||||
offset,
|
||||
index,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
143
apps/context/apps/mobile/components/documents/DocumentHeader.tsx
Normal file
143
apps/context/apps/mobile/components/documents/DocumentHeader.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { Breadcrumbs } from '~/components/navigation/Breadcrumbs';
|
||||
import { DocumentToolbar } from './DocumentToolbar';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { DocumentType } from './DocumentTypeDropdown';
|
||||
|
||||
interface DocumentHeaderProps {
|
||||
// Required props
|
||||
title: string;
|
||||
spaceName: string;
|
||||
isNewDocument: boolean;
|
||||
|
||||
// Optional props for simple usage (DocumentEditor.tsx)
|
||||
onTitleChange?: (title: string) => void;
|
||||
onNavigateToSpace?: () => void;
|
||||
onNavigateToNext?: () => void;
|
||||
nextDocumentTitle?: string;
|
||||
className?: string;
|
||||
|
||||
// Optional props for complex usage (old implementation)
|
||||
documentId?: string;
|
||||
spaceId?: string | null;
|
||||
showPreview?: boolean;
|
||||
setShowPreview?: (show: boolean) => void;
|
||||
saving?: boolean;
|
||||
saveDocument?: () => void;
|
||||
unsavedChanges?: boolean;
|
||||
documentType?: DocumentType | undefined;
|
||||
handleTypeChange?: (type: DocumentType) => void;
|
||||
handleVersionChange?: (version: any) => void;
|
||||
spaceDocuments?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
}>;
|
||||
showTagsEditor?: boolean;
|
||||
setShowTagsEditor?: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export const DocumentHeader: React.FC<DocumentHeaderProps> = ({
|
||||
documentId,
|
||||
spaceId,
|
||||
title,
|
||||
spaceName,
|
||||
showPreview,
|
||||
setShowPreview,
|
||||
isNewDocument,
|
||||
saving,
|
||||
saveDocument,
|
||||
unsavedChanges,
|
||||
documentType,
|
||||
handleTypeChange,
|
||||
handleVersionChange,
|
||||
spaceDocuments = [],
|
||||
showTagsEditor,
|
||||
setShowTagsEditor,
|
||||
// Simple usage props
|
||||
onTitleChange,
|
||||
onNavigateToSpace,
|
||||
onNavigateToNext,
|
||||
nextDocumentTitle,
|
||||
className,
|
||||
}) => {
|
||||
const { width } = useWindowDimensions();
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
const isWideScreen = width >= 640; // Reduzierter Breakpoint, da wir weniger Elemente haben
|
||||
|
||||
// Simple usage (from DocumentEditor.tsx) - just show breadcrumbs
|
||||
if (onTitleChange !== undefined || onNavigateToSpace !== undefined) {
|
||||
return (
|
||||
<View style={styles.headerContainer}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Spaces', href: '/' },
|
||||
{ label: spaceName, href: onNavigateToSpace ? '#' : undefined },
|
||||
{ label: isNewDocument ? 'Neues Dokument' : title || 'Unbenanntes Dokument' },
|
||||
]}
|
||||
className="justify-start"
|
||||
loading={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Complex usage (old implementation) - just show breadcrumbs for now
|
||||
// The old DocumentToolbar interface doesn't match the simple one we're using
|
||||
return (
|
||||
<View style={styles.headerContainer}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Spaces', href: '/' },
|
||||
{ label: spaceName, href: `/spaces/${spaceId}` },
|
||||
{
|
||||
label: isNewDocument ? 'Neues Dokument' : title || 'Unbenanntes Dokument',
|
||||
dropdownItems: spaceDocuments.map((doc) => ({
|
||||
id: doc.id,
|
||||
label: doc.title || 'Unbenanntes Dokument',
|
||||
href: `/spaces/${spaceId}/documents/${doc.id}`,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
className="justify-start"
|
||||
loading={false}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerContainer: {
|
||||
width: '100%',
|
||||
backgroundColor: 'var(--color-background)',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'var(--color-border)',
|
||||
zIndex: 1000,
|
||||
},
|
||||
wideContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
maxWidth: 1200,
|
||||
marginHorizontal: 'auto',
|
||||
},
|
||||
narrowContainer: {
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
},
|
||||
breadcrumbsWide: {
|
||||
flex: 1,
|
||||
paddingLeft: 16,
|
||||
},
|
||||
toolbarWide: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
breadcrumbsNarrow: {
|
||||
width: '100%',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface DocumentSelectionIndicatorProps {
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const DocumentSelectionIndicator: React.FC<DocumentSelectionIndicatorProps> = ({
|
||||
isSelected,
|
||||
onToggle,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: isSelected
|
||||
? isDark
|
||||
? 'rgba(99, 102, 241, 0.08)'
|
||||
: 'rgba(79, 70, 229, 0.05)'
|
||||
: 'transparent',
|
||||
},
|
||||
]}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.indicator,
|
||||
{
|
||||
backgroundColor: isSelected ? (isDark ? '#6366f1' : '#4f46e5') : 'transparent',
|
||||
borderColor: isSelected
|
||||
? isDark
|
||||
? '#6366f1'
|
||||
: '#4f46e5'
|
||||
: isDark
|
||||
? '#4b5563'
|
||||
: '#d1d5db',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{isSelected && <Ionicons name="checkmark" size={16} color="#ffffff" style={styles.icon} />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
indicator: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 0, // Eckige Border
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
icon: {
|
||||
marginTop: -1,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { View, useWindowDimensions, Dimensions } from 'react-native';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { Skeleton } from '~/components/ui/Skeleton';
|
||||
|
||||
interface DocumentSkeletonProps {
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton-Komponente für die Dokumentansicht während des Ladens - maximal vereinfacht
|
||||
*/
|
||||
export const DocumentSkeleton: React.FC<DocumentSkeletonProps> = ({ isPreview = true }) => {
|
||||
const { isDark } = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const isDesktop = width > 1024;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
backgroundColor: isDark ? '#111827' : '#f9fafb',
|
||||
}}
|
||||
>
|
||||
{/* Header - minimal */}
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 50,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? '#374151' : '#e5e7eb',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hauptinhalt */}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
marginHorizontal: 'auto',
|
||||
maxWidth: isDesktop ? 800 : '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
borderRadius: 0,
|
||||
marginTop: 16,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Keyboard,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { themes } from '~/utils/theme/colors';
|
||||
// Import von saveDocumentTags entfernt, da die Speicherung jetzt in der übergeordneten Komponente erfolgt
|
||||
|
||||
interface DocumentTagsEditorProps {
|
||||
tags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
themeName?: string;
|
||||
documentId?: string;
|
||||
}
|
||||
|
||||
export const DocumentTagsEditor: React.FC<DocumentTagsEditorProps> = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
themeName = 'indigo',
|
||||
documentId,
|
||||
}) => {
|
||||
// Debug-Ausgabe
|
||||
console.log('DocumentTagsEditor - documentId:', documentId);
|
||||
console.log('DocumentTagsEditor - tags:', tags);
|
||||
const { isDark } = useTheme();
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Funktion zum Hinzufügen eines neuen Tags
|
||||
const handleAddTag = () => {
|
||||
const trimmedTag = newTag.trim();
|
||||
if (trimmedTag && !tags.includes(trimmedTag)) {
|
||||
const newTags = [...tags, trimmedTag];
|
||||
|
||||
// Setze den neuen Tag im Eingabefeld zurück
|
||||
setNewTag('');
|
||||
|
||||
// Setze den Lade-Indikator
|
||||
setIsSaving(true);
|
||||
|
||||
// Rufe die übergeordnete onTagsChange-Funktion auf, die sich um die Speicherung kümmert
|
||||
onTagsChange(newTags);
|
||||
|
||||
// Setze den Lade-Indikator nach einer kurzen Verzögerung zurück
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Entfernen eines Tags
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
const newTags = tags.filter((tag) => tag !== tagToRemove);
|
||||
|
||||
// Setze den Lade-Indikator
|
||||
setIsSaving(true);
|
||||
|
||||
// Rufe die übergeordnete onTagsChange-Funktion auf, die sich um die Speicherung kümmert
|
||||
onTagsChange(newTags);
|
||||
|
||||
// Setze den Lade-Indikator nach einer kurzen Verzögerung zurück
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Tastatur-Event-Handler für Enter-Taste
|
||||
const handleKeyPress = (e: any) => {
|
||||
if (e.nativeEvent.key === 'Enter' || e.nativeEvent.key === ',') {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container} className="document-tags-editor">
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={[styles.label, { color: isDark ? '#d1d5db' : '#4b5563' }]}>Tags</Text>
|
||||
{isSaving && <ActivityIndicator size="small" color={isDark ? '#d1d5db' : '#4b5563'} />}
|
||||
</View>
|
||||
|
||||
{/* Tag-Liste */}
|
||||
<View style={styles.tagsContainer}>
|
||||
{tags.map((tag, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.tag,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#f3f4f6',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.removeButton} onPress={() => handleRemoveTag(tag)}>
|
||||
<Ionicons name="close-circle" size={16} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Eingabefeld für neue Tags */}
|
||||
<View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
{
|
||||
borderColor: isDark ? '#374151' : '#d1d5db',
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={newTag}
|
||||
onChangeText={setNewTag}
|
||||
onKeyPress={handleKeyPress}
|
||||
onSubmitEditing={handleAddTag}
|
||||
placeholder="Neuen Tag hinzufügen (mit Enter oder Komma bestätigen)"
|
||||
placeholderTextColor={isDark ? '#6b7280' : '#9ca3af'}
|
||||
style={[styles.input, { color: isDark ? '#f3f4f6' : '#111827' }]}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.addButton,
|
||||
{
|
||||
backgroundColor: newTag.trim()
|
||||
? isDark
|
||||
? '#4f46e5'
|
||||
: '#6366f1'
|
||||
: isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb',
|
||||
opacity: newTag.trim() ? 1 : 0.5,
|
||||
},
|
||||
]}
|
||||
onPress={handleAddTag}
|
||||
disabled={!newTag.trim()}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 8,
|
||||
},
|
||||
tag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 9999,
|
||||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
removeButton: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
addButton: {
|
||||
height: 40,
|
||||
width: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Platform,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface DocumentTagsFilterProps {
|
||||
allTags: string[];
|
||||
selectedTags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// TagItem als separate Komponente
|
||||
const TagItem = ({
|
||||
tag,
|
||||
onSelect,
|
||||
isSelected,
|
||||
isDark,
|
||||
}: {
|
||||
tag: string;
|
||||
onSelect: () => void;
|
||||
isSelected: boolean;
|
||||
isDark: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tagItem,
|
||||
{
|
||||
backgroundColor: isSelected
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#f3f4f6'
|
||||
: isDark
|
||||
? '#1f2937'
|
||||
: '#ffffff',
|
||||
},
|
||||
]}
|
||||
onPress={onSelect}
|
||||
>
|
||||
<View style={styles.tagItemContent}>
|
||||
<Ionicons
|
||||
name={isSelected ? 'checkmark-circle' : 'pricetag-outline'}
|
||||
size={18}
|
||||
color={isSelected ? (isDark ? '#10b981' : '#059669') : isDark ? '#9ca3af' : '#6b7280'}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: isDark ? '#f9fafb' : '#111827', fontSize: 14, fontWeight: '500' }}>
|
||||
{tag}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentTagsFilter: React.FC<DocumentTagsFilterProps> = ({
|
||||
allTags,
|
||||
selectedTags,
|
||||
onTagsChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const buttonRef = useRef<any>(null);
|
||||
|
||||
// Toggle-Funktion für Tags
|
||||
const handleTagSelect = (tag: string) => {
|
||||
if (selectedTags.includes(tag)) {
|
||||
onTagsChange(selectedTags.filter((t) => t !== tag));
|
||||
} else {
|
||||
onTagsChange([...selectedTags, tag]);
|
||||
}
|
||||
};
|
||||
|
||||
// Alle Tags löschen
|
||||
const clearAllTags = () => {
|
||||
onTagsChange([]);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
ref={buttonRef}
|
||||
onPress={() => setIsOpen(true)}
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
<Ionicons name="pricetag-outline" size={18} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
<Text style={[styles.buttonText, { color: isDark ? '#d1d5db' : '#4b5563' }]}>
|
||||
{selectedTags.length > 0 ? `Tags (${selectedTags.length})` : 'Tags'}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={16} color={isDark ? '#9ca3af' : '#6b7280'} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalOverlay} onPress={() => setIsOpen(false)}>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text
|
||||
style={{ fontSize: 16, fontWeight: 'bold', color: isDark ? '#f9fafb' : '#111827' }}
|
||||
>
|
||||
Tags filtern
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setIsOpen(false)}>
|
||||
<Ionicons name="close" size={24} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.modalScroll}>
|
||||
{allTags.length > 0 ? (
|
||||
allTags.map((tag) => (
|
||||
<TagItem
|
||||
key={tag}
|
||||
tag={tag}
|
||||
onSelect={() => handleTagSelect(tag)}
|
||||
isSelected={selectedTags.includes(tag)}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={{ color: isDark ? '#9ca3af' : '#6b7280', textAlign: 'center' }}>
|
||||
Keine Tags verfügbar
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{selectedTags.length > 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.clearButton, { borderTopColor: isDark ? '#374151' : '#e5e7eb' }]}
|
||||
onPress={clearAllTags}
|
||||
>
|
||||
<Text style={{ color: isDark ? '#f87171' : '#ef4444' }}>Alle Filter löschen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
marginLeft: 8,
|
||||
marginRight: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
tagItem: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
tagItemContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
clearButton: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderTopWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
width: '80%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
modalScroll: {
|
||||
maxHeight: 300,
|
||||
},
|
||||
emptyState: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import React from 'react';
|
||||
import { View, ScrollView, Text, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { FilterPill } from '~/components/ui/FilterPill';
|
||||
|
||||
interface DocumentTagsPillsProps {
|
||||
allTags: string[];
|
||||
selectedTags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DocumentTagsPills: React.FC<DocumentTagsPillsProps> = ({
|
||||
allTags,
|
||||
selectedTags,
|
||||
onTagsChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
// Toggle-Funktion für Tags
|
||||
const toggleTag = (tag: string) => {
|
||||
if (selectedTags.includes(tag)) {
|
||||
onTagsChange(selectedTags.filter((t) => t !== tag));
|
||||
} else {
|
||||
onTagsChange([...selectedTags, tag]);
|
||||
}
|
||||
};
|
||||
|
||||
// Alle Tags löschen
|
||||
const clearAllTags = () => {
|
||||
onTagsChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* "Alle Tags" Pill - nur anzeigen, wenn Tags ausgewählt sind */}
|
||||
{selectedTags.length > 0 && (
|
||||
<FilterPill
|
||||
label="Alle Tags"
|
||||
icon="close-circle"
|
||||
variant="document"
|
||||
onPress={clearAllTags}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tag Pills */}
|
||||
{allTags.map((tag) => (
|
||||
<FilterPill
|
||||
key={tag}
|
||||
label={tag}
|
||||
icon="pricetag-outline"
|
||||
variant="document"
|
||||
isSelected={selectedTags.includes(tag)}
|
||||
onPress={() => toggleTag(tag)}
|
||||
disabled={disabled}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
import React from 'react';
|
||||
import { View, TouchableOpacity, Platform } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { SaveIndicator } from '~/components/ui/SaveIndicator';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { DocumentMode } from '~/types/documentEditor';
|
||||
import { EDITOR_CONFIG } from '~/config/editorConfig';
|
||||
|
||||
export interface DocumentToolbarProps {
|
||||
mode: DocumentMode;
|
||||
onToggleMode: () => void;
|
||||
onSave: () => void;
|
||||
onShowTags: () => void;
|
||||
onShowVariantCreator: () => void;
|
||||
|
||||
// Save status
|
||||
saveStatus: 'idle' | 'saving' | 'saved' | 'error';
|
||||
lastSaved?: Date | null;
|
||||
saveError?: string | null;
|
||||
|
||||
// State
|
||||
canSave: boolean;
|
||||
isGeneratingText: boolean;
|
||||
showTagsEditor: boolean;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar-Komponente für den Dokumenten-Editor
|
||||
* Enthält Mode-Toggle, Save-Button, Tags-Button und Save-Indicator
|
||||
*/
|
||||
export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
|
||||
mode,
|
||||
onToggleMode,
|
||||
onSave,
|
||||
onShowTags,
|
||||
onShowVariantCreator,
|
||||
saveStatus,
|
||||
lastSaved,
|
||||
saveError,
|
||||
canSave,
|
||||
isGeneratingText,
|
||||
showTagsEditor,
|
||||
className,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const getButtonColor = (isActive: boolean) => {
|
||||
if (isActive) {
|
||||
return isDark ? '#4f46e5' : '#6366f1';
|
||||
}
|
||||
return isDark ? '#6b7280' : '#9ca3af';
|
||||
};
|
||||
|
||||
const getButtonBackground = (isActive: boolean) => {
|
||||
if (isActive) {
|
||||
return isDark ? '#1e1b4b' : '#e0e7ff';
|
||||
}
|
||||
return 'transparent';
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
className={className}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: isDark ? '#374151' : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
{/* Left side - Mode Toggle */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<TouchableOpacity
|
||||
onPress={onToggleMode}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
backgroundColor: getButtonBackground(mode === 'edit'),
|
||||
marginRight: 8,
|
||||
}}
|
||||
disabled={isGeneratingText}
|
||||
>
|
||||
<Ionicons
|
||||
name={mode === 'edit' ? 'create' : 'create-outline'}
|
||||
size={16}
|
||||
color={getButtonColor(mode === 'edit')}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: getButtonColor(mode === 'edit'),
|
||||
fontWeight: mode === 'edit' ? '600' : '400',
|
||||
}}
|
||||
>
|
||||
Bearbeiten
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onToggleMode}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
backgroundColor: getButtonBackground(mode === 'preview'),
|
||||
marginRight: 16,
|
||||
}}
|
||||
disabled={isGeneratingText}
|
||||
>
|
||||
<Ionicons
|
||||
name={mode === 'preview' ? 'eye' : 'eye-outline'}
|
||||
size={16}
|
||||
color={getButtonColor(mode === 'preview')}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: getButtonColor(mode === 'preview'),
|
||||
fontWeight: mode === 'preview' ? '600' : '400',
|
||||
}}
|
||||
>
|
||||
Vorschau
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Save Button */}
|
||||
<TouchableOpacity
|
||||
onPress={onSave}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
backgroundColor: canSave ? (isDark ? '#059669' : '#10b981') : 'transparent',
|
||||
opacity: canSave ? 1 : 0.5,
|
||||
marginRight: 8,
|
||||
}}
|
||||
disabled={!canSave || saveStatus === 'saving'}
|
||||
>
|
||||
<Ionicons
|
||||
name="save"
|
||||
size={16}
|
||||
color={canSave ? '#ffffff' : isDark ? '#9ca3af' : '#6b7280'}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: canSave ? '#ffffff' : isDark ? '#9ca3af' : '#6b7280',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Center - Save Indicator */}
|
||||
<SaveIndicator
|
||||
status={saveStatus}
|
||||
lastSaved={lastSaved}
|
||||
error={saveError}
|
||||
className="flex-1 justify-center"
|
||||
/>
|
||||
|
||||
{/* Right side - Action Buttons */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{/* Tags Button */}
|
||||
<TouchableOpacity
|
||||
onPress={onShowTags}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
backgroundColor: getButtonBackground(showTagsEditor),
|
||||
marginRight: 8,
|
||||
}}
|
||||
disabled={isGeneratingText}
|
||||
>
|
||||
<Ionicons
|
||||
name={showTagsEditor ? 'pricetags' : 'pricetags-outline'}
|
||||
size={16}
|
||||
color={getButtonColor(showTagsEditor)}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: getButtonColor(showTagsEditor),
|
||||
fontWeight: showTagsEditor ? '600' : '400',
|
||||
}}
|
||||
>
|
||||
Tags
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Variant Creator Button */}
|
||||
<TouchableOpacity
|
||||
onPress={onShowVariantCreator}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
backgroundColor: isDark ? '#7c3aed' : '#8b5cf6',
|
||||
opacity: isGeneratingText ? 0.5 : 1,
|
||||
}}
|
||||
disabled={isGeneratingText}
|
||||
>
|
||||
<Ionicons name="sparkles" size={16} color="#ffffff" style={{ marginRight: 4 }} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: '#ffffff',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
AI
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Keyboard Shortcuts Info Component
|
||||
* Zeigt verfügbare Tastenkürzel an
|
||||
*/
|
||||
export const KeyboardShortcutsInfo: React.FC = () => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
if (Platform.OS !== 'web') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Tastenkürzel: Strg+S (Speichern) • Strg+P (Vorschau) • Strg+K (Fokus)
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface DocumentTypeBadgeProps {
|
||||
type: 'original' | 'generated' | 'context' | 'prompt';
|
||||
size?: 'small' | 'medium';
|
||||
}
|
||||
|
||||
export const DocumentTypeBadge: React.FC<DocumentTypeBadgeProps> = ({ type, size = 'medium' }) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// Bestimme die Farben basierend auf dem Dokumenttyp
|
||||
const getTypeColor = () => {
|
||||
switch (type) {
|
||||
case 'original':
|
||||
return '#2563eb';
|
||||
case 'context':
|
||||
return '#16a34a';
|
||||
case 'prompt':
|
||||
return '#d97706';
|
||||
case 'generated':
|
||||
return '#0891b2';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
// Bestimme die Hintergrundfarbe mit Transparenz
|
||||
const getTypeBackgroundColor = () => {
|
||||
switch (type) {
|
||||
case 'original':
|
||||
return 'rgba(37, 99, 235, 0.1)';
|
||||
case 'context':
|
||||
return 'rgba(22, 163, 74, 0.1)';
|
||||
case 'prompt':
|
||||
return 'rgba(217, 119, 6, 0.1)';
|
||||
case 'generated':
|
||||
return 'rgba(8, 145, 178, 0.1)';
|
||||
default:
|
||||
return 'rgba(107, 114, 128, 0.1)';
|
||||
}
|
||||
};
|
||||
|
||||
// Bestimme das Label für den Dokumenttyp
|
||||
const getTypeLabel = () => {
|
||||
switch (type) {
|
||||
case 'original':
|
||||
return 'Original';
|
||||
case 'context':
|
||||
return 'Kontext';
|
||||
case 'prompt':
|
||||
return 'Prompt';
|
||||
case 'generated':
|
||||
return 'Generiert';
|
||||
default:
|
||||
return 'Dokument';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: size === 'small' ? 5 : 7,
|
||||
paddingVertical: size === 'small' ? 1 : 2, // Deutlich flacher
|
||||
borderRadius: 3,
|
||||
backgroundColor: getTypeBackgroundColor(),
|
||||
alignSelf: 'flex-start',
|
||||
height: size === 'small' ? 16 : 20, // Feste Höhe für konsistente Darstellung
|
||||
justifyContent: 'center', // Zentriert den Text vertikal
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: size === 'small' ? 9 : 11, // Kleinere Schrift
|
||||
fontWeight: '500',
|
||||
color: getTypeColor(),
|
||||
lineHeight: size === 'small' ? 14 : 16, // Angepasste Zeilenhöhe
|
||||
}}
|
||||
>
|
||||
{getTypeLabel()}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
import React, { useState, useRef, memo } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ScrollView, Platform } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
export type DocumentType = 'text' | 'context' | 'prompt';
|
||||
|
||||
interface DocumentTypeDropdownProps {
|
||||
currentType: DocumentType;
|
||||
onTypeChange: (type: DocumentType) => void;
|
||||
disabled?: boolean;
|
||||
openUpwards?: boolean;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
interface TypeOption {
|
||||
value: DocumentType;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
color: {
|
||||
light: string;
|
||||
dark: string;
|
||||
};
|
||||
}
|
||||
|
||||
// TypeItem als separate Komponente
|
||||
const TypeItem = memo(
|
||||
({
|
||||
item,
|
||||
onSelect,
|
||||
isSelected,
|
||||
isDark,
|
||||
}: {
|
||||
item: TypeOption;
|
||||
onSelect: () => void;
|
||||
isSelected: boolean;
|
||||
isDark: boolean;
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
// Vorberechnete Farben
|
||||
const bgColor = isSelected
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#f3f4f6'
|
||||
: isPressed
|
||||
? isDark
|
||||
? '#2d3748'
|
||||
: '#f9fafb'
|
||||
: isHovered
|
||||
? isDark
|
||||
? '#2d3748'
|
||||
: '#f9fafb'
|
||||
: isDark
|
||||
? '#1f2937'
|
||||
: '#ffffff';
|
||||
|
||||
const iconBgColor = isDark ? `${item.color.dark}30` : `${item.color.light}20`;
|
||||
const iconColor = isDark ? item.color.dark : item.color.light;
|
||||
const textColor = isDark ? '#f9fafb' : '#111827';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.typeItem, { backgroundColor: bgColor }]}
|
||||
onPress={onSelect}
|
||||
onHoverIn={() => Platform.OS === 'web' && setIsHovered(true)}
|
||||
onHoverOut={() => Platform.OS === 'web' && setIsHovered(false)}
|
||||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
>
|
||||
<View style={styles.typeItemContent}>
|
||||
<View style={styles.typeItemHeader}>
|
||||
<View style={[styles.typeIcon, { backgroundColor: iconBgColor }]}>
|
||||
<Ionicons name={item.icon as any} size={18} color={iconColor} />
|
||||
</View>
|
||||
<Text style={[styles.typeLabel, { color: textColor }]}>{item.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Statische Dokumenttypen - keine Neuberechnung notwendig
|
||||
const typeOptions: TypeOption[] = [
|
||||
{
|
||||
value: 'text',
|
||||
label: 'Text',
|
||||
icon: 'document-text-outline',
|
||||
description: 'Importierte oder manuell erstellte Texte',
|
||||
color: {
|
||||
light: '#ef4444', // Rot
|
||||
dark: '#f87171',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'context',
|
||||
label: 'Kontext',
|
||||
icon: 'information-circle-outline',
|
||||
description: 'Texte, die als Kontext für KI-Anfragen dienen',
|
||||
color: {
|
||||
light: '#16a34a', // Grün
|
||||
dark: '#4ade80',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'prompt',
|
||||
label: 'Prompt',
|
||||
icon: 'chatbubble-outline',
|
||||
description: 'Prompts für KI-Modelle',
|
||||
color: {
|
||||
light: '#d97706', // Orange
|
||||
dark: '#fbbf24',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const DocumentTypeDropdown: React.FC<DocumentTypeDropdownProps> = ({
|
||||
currentType,
|
||||
onTypeChange,
|
||||
disabled = false,
|
||||
openUpwards = false,
|
||||
style,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const buttonRef = useRef<View>(null);
|
||||
|
||||
// Aktueller Typ
|
||||
const currentTypeOption =
|
||||
typeOptions.find((option) => option.value === currentType) || typeOptions[0];
|
||||
|
||||
// Vorberechnete Farben und Styles
|
||||
const buttonBgColor = disabled
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb'
|
||||
: isPressed
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb'
|
||||
: isHovered
|
||||
? isDark
|
||||
? '#2d3748'
|
||||
: '#f1f2f4'
|
||||
: isDark
|
||||
? '#1f2937'
|
||||
: '#f3f4f6';
|
||||
|
||||
const iconBgColor = isDark
|
||||
? `${currentTypeOption.color.dark}30`
|
||||
: `${currentTypeOption.color.light}20`;
|
||||
const iconColor = isDark ? currentTypeOption.color.dark : currentTypeOption.color.light;
|
||||
const textColor = isDark ? '#f9fafb' : '#111827';
|
||||
const chevronColor = isDark ? '#9ca3af' : '#6b7280';
|
||||
|
||||
// Handler für Typ-Auswahl
|
||||
const handleTypeSelect = (type: DocumentType) => {
|
||||
onTypeChange(type);
|
||||
setDropdownVisible(false);
|
||||
};
|
||||
|
||||
// Toggle-Funktion für Dropdown
|
||||
const toggleDropdown = () => {
|
||||
if (disabled) return;
|
||||
setDropdownVisible(!dropdownVisible);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]} ref={buttonRef}>
|
||||
{/* Button, der den aktuellen Typ anzeigt */}
|
||||
<Pressable
|
||||
onPress={toggleDropdown}
|
||||
disabled={disabled}
|
||||
style={[
|
||||
styles.typeButton,
|
||||
{ backgroundColor: buttonBgColor },
|
||||
disabled && { opacity: 0.6 },
|
||||
]}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
>
|
||||
<View style={[styles.typeIcon, { backgroundColor: iconBgColor }]}>
|
||||
<Ionicons name={currentTypeOption.icon as any} size={18} color={iconColor} />
|
||||
</View>
|
||||
<Text style={[styles.typeLabel, { color: textColor }]}>{currentTypeOption.label}</Text>
|
||||
<Ionicons
|
||||
name={dropdownVisible ? 'chevron-up' : 'chevron-down'}
|
||||
size={16}
|
||||
color={chevronColor}
|
||||
style={styles.dropdownIcon}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Dropdown für die Typauswahl */}
|
||||
{dropdownVisible && (
|
||||
<View
|
||||
style={[
|
||||
styles.dropdownContent,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
position: 'absolute',
|
||||
...(openUpwards ? { bottom: 40, left: 0 } : { top: 40, left: 0 }),
|
||||
width: 140, // Feste Breite
|
||||
zIndex: 5, // Moderater Z-Index
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ScrollView style={styles.typeList} showsVerticalScrollIndicator={false}>
|
||||
{typeOptions.map((item) => (
|
||||
<TypeItem
|
||||
key={item.value}
|
||||
item={item}
|
||||
onSelect={() => handleTypeSelect(item.value)}
|
||||
isSelected={currentType === item.value}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
zIndex: 1, // Niedriger Z-Index
|
||||
},
|
||||
typeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
height: 36,
|
||||
},
|
||||
typeIcon: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
typeLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
dropdownIcon: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
dropdownContent: {
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 2,
|
||||
elevation: 3, // Niedriger Elevation-Wert
|
||||
},
|
||||
typeList: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
typeItem: {
|
||||
padding: 8,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255, 255, 255, 0.12)', // white/12
|
||||
},
|
||||
typeItemContent: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
typeItemHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react';
|
||||
import { ScrollView } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FilterPill } from '~/components/ui/FilterPill';
|
||||
|
||||
export type DocumentType = 'original' | 'generated' | 'context' | 'prompt';
|
||||
|
||||
interface DocumentTypeFilterProps {
|
||||
selectedType: DocumentType | null;
|
||||
onTypeChange: (type: DocumentType | null) => void;
|
||||
}
|
||||
|
||||
interface FilterOption {
|
||||
value: DocumentType;
|
||||
label: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
color: {
|
||||
light: string;
|
||||
dark: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DocumentTypeFilter: React.FC<DocumentTypeFilterProps> = ({
|
||||
selectedType,
|
||||
onTypeChange,
|
||||
}) => {
|
||||
const filterOptions: FilterOption[] = [
|
||||
{
|
||||
value: 'original',
|
||||
label: 'Original',
|
||||
icon: 'document-text-outline',
|
||||
color: {
|
||||
light: '#2563eb',
|
||||
dark: '#3b82f6',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'generated',
|
||||
label: 'Generiert',
|
||||
icon: 'sparkles-outline',
|
||||
color: {
|
||||
light: '#0891b2',
|
||||
dark: '#06b6d4',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'context',
|
||||
label: 'Kontext',
|
||||
icon: 'information-circle-outline',
|
||||
color: {
|
||||
light: '#16a34a',
|
||||
dark: '#22c55e',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'prompt',
|
||||
label: 'Prompt',
|
||||
icon: 'chatbubble-outline',
|
||||
color: {
|
||||
light: '#d97706',
|
||||
dark: '#f59e0b',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Keine 'Alle' Option mehr, da wir jetzt Toggle-Funktionalität haben
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
paddingLeft: 16, // Padding links für Abstand zum Rand
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
paddingRight: 32, // Ausreichendes Padding am Ende
|
||||
}}
|
||||
>
|
||||
{filterOptions.map((option) => (
|
||||
<FilterPill
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
icon={option.icon}
|
||||
variant="document"
|
||||
isSelected={selectedType === option.value}
|
||||
onPress={() => {
|
||||
// Wenn der Filter bereits ausgewählt ist, deselektieren (null setzen)
|
||||
if (selectedType === option.value) {
|
||||
onTypeChange(null);
|
||||
} else {
|
||||
onTypeChange(option.value);
|
||||
}
|
||||
}}
|
||||
color={option.color}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
import React, { useState, useRef, memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Platform,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
export type FilterType = 'text' | 'context' | 'prompt' | null;
|
||||
|
||||
interface DocumentTypeFilterDropdownProps {
|
||||
selectedType: FilterType;
|
||||
onTypeChange: (type: FilterType) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TypeOption {
|
||||
value: FilterType;
|
||||
label: string;
|
||||
icon: string;
|
||||
color: {
|
||||
light: string;
|
||||
dark: string;
|
||||
};
|
||||
}
|
||||
|
||||
// TypeItem als separate Komponente
|
||||
const TypeItem = memo(
|
||||
({
|
||||
item,
|
||||
onSelect,
|
||||
isSelected,
|
||||
isDark,
|
||||
}: {
|
||||
item: TypeOption;
|
||||
onSelect: () => void;
|
||||
isSelected: boolean;
|
||||
isDark: boolean;
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
// Vorberechnete Farben
|
||||
const bgColor = isSelected
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#f3f4f6'
|
||||
: isPressed
|
||||
? isDark
|
||||
? '#2d3748'
|
||||
: '#f9fafb'
|
||||
: isHovered
|
||||
? isDark
|
||||
? '#2d3748'
|
||||
: '#f9fafb'
|
||||
: isDark
|
||||
? '#1f2937'
|
||||
: '#ffffff';
|
||||
|
||||
const iconBgColor = item.value
|
||||
? isDark
|
||||
? `${item.color.dark}30`
|
||||
: `${item.color.light}20`
|
||||
: isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb';
|
||||
|
||||
const iconColor = item.value
|
||||
? isDark
|
||||
? item.color.dark
|
||||
: item.color.light
|
||||
: isDark
|
||||
? '#d1d5db'
|
||||
: '#4b5563';
|
||||
|
||||
const textColor = isDark ? '#f9fafb' : '#111827';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.typeItem, { backgroundColor: bgColor }]}
|
||||
onPress={onSelect}
|
||||
onHoverIn={() => Platform.OS === 'web' && setIsHovered(true)}
|
||||
onHoverOut={() => Platform.OS === 'web' && setIsHovered(false)}
|
||||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
>
|
||||
<View style={styles.typeItemContent}>
|
||||
<View style={styles.typeItemHeader}>
|
||||
<Ionicons
|
||||
name={item.icon as any}
|
||||
size={18}
|
||||
color={iconColor}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={[styles.typeLabel, { color: textColor }]}>{item.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Statische Dokumenttypen - keine Neuberechnung notwendig
|
||||
const typeOptions: TypeOption[] = [
|
||||
{
|
||||
value: null,
|
||||
label: 'Alle',
|
||||
icon: 'apps-outline',
|
||||
color: {
|
||||
light: '#4b5563',
|
||||
dark: '#9ca3af',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'text',
|
||||
label: 'Text',
|
||||
icon: 'document-text-outline',
|
||||
color: {
|
||||
light: '#ef4444', // Rot
|
||||
dark: '#f87171',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'context',
|
||||
label: 'Kontext',
|
||||
icon: 'information-circle-outline',
|
||||
color: {
|
||||
light: '#16a34a', // Grün
|
||||
dark: '#4ade80',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'prompt',
|
||||
label: 'Prompt',
|
||||
icon: 'chatbubble-outline',
|
||||
color: {
|
||||
light: '#d97706', // Orange
|
||||
dark: '#fbbf24',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const DocumentTypeFilterDropdown: React.FC<DocumentTypeFilterDropdownProps> = ({
|
||||
selectedType,
|
||||
onTypeChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const buttonRef = useRef<View>(null);
|
||||
const [buttonPosition, setButtonPosition] = useState({ top: 0, right: 0, width: 0 });
|
||||
|
||||
// Aktueller Typ
|
||||
const currentTypeOption =
|
||||
typeOptions.find((option) => option.value === selectedType) || typeOptions[0];
|
||||
|
||||
// Vorberechnete Farben und Styles
|
||||
const buttonBgColor = disabled
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb'
|
||||
: isPressed
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb'
|
||||
: isHovered
|
||||
? isDark
|
||||
? '#2d3748'
|
||||
: '#f1f2f4'
|
||||
: isDark
|
||||
? '#1f2937'
|
||||
: '#f3f4f6';
|
||||
|
||||
const iconBgColor = currentTypeOption.value
|
||||
? isDark
|
||||
? `${currentTypeOption.color.dark}30`
|
||||
: `${currentTypeOption.color.light}20`
|
||||
: isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb';
|
||||
|
||||
const iconColor = currentTypeOption.value
|
||||
? isDark
|
||||
? currentTypeOption.color.dark
|
||||
: currentTypeOption.color.light
|
||||
: isDark
|
||||
? '#d1d5db'
|
||||
: '#4b5563';
|
||||
|
||||
const textColor = isDark ? '#f9fafb' : '#111827';
|
||||
const chevronColor = isDark ? '#9ca3af' : '#6b7280';
|
||||
|
||||
// Handler für Typ-Auswahl
|
||||
const handleTypeSelect = (type: FilterType) => {
|
||||
onTypeChange(type);
|
||||
setDropdownVisible(false);
|
||||
};
|
||||
|
||||
// Toggle-Funktion für Dropdown
|
||||
const toggleDropdown = () => {
|
||||
if (disabled) return;
|
||||
setDropdownVisible(!dropdownVisible);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container} ref={buttonRef}>
|
||||
{/* Button, der den aktuellen Typ anzeigt */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
// Messen der Button-Position vor dem Öffnen des Dropdowns
|
||||
if (buttonRef.current && Platform.OS === 'web') {
|
||||
// @ts-ignore - getBoundingClientRect ist auf Web verfügbar
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setButtonPosition({
|
||||
top: rect.bottom,
|
||||
right: window.innerWidth - rect.right,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
toggleDropdown();
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={[
|
||||
styles.typeButton,
|
||||
{ backgroundColor: buttonBgColor },
|
||||
disabled && { opacity: 0.6 },
|
||||
]}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
>
|
||||
<Ionicons name={currentTypeOption.icon as any} size={18} color={iconColor} />
|
||||
<Text style={[styles.typeLabel, { color: textColor, marginLeft: 8 }]}>
|
||||
{currentTypeOption.label}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={dropdownVisible ? 'chevron-up' : 'chevron-down'}
|
||||
size={16}
|
||||
color={chevronColor}
|
||||
style={styles.dropdownIcon}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Dropdown für die Typauswahl als Modal */}
|
||||
{dropdownVisible && (
|
||||
<Modal
|
||||
transparent={true}
|
||||
visible={dropdownVisible}
|
||||
onRequestClose={() => setDropdownVisible(false)}
|
||||
animationType="fade"
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
activeOpacity={1}
|
||||
onPress={() => setDropdownVisible(false)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: buttonPosition.top + 5, // Leichter Abstand unter dem Button
|
||||
right: buttonPosition.right,
|
||||
width: Math.max(buttonPosition.width, 140), // Mindestens so breit wie der Button
|
||||
borderRadius: 8,
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 3,
|
||||
elevation: 5,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} onPress={(e) => e.stopPropagation()}>
|
||||
<ScrollView style={styles.typeList} showsVerticalScrollIndicator={false}>
|
||||
{typeOptions.map((item) => (
|
||||
<TypeItem
|
||||
key={item.value || 'all'}
|
||||
item={item}
|
||||
onSelect={() => handleTypeSelect(item.value)}
|
||||
isSelected={selectedType === item.value}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
zIndex: 10, // Höherer Z-Index, damit das Dropdown über anderen Elementen angezeigt wird
|
||||
},
|
||||
typeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
height: 36,
|
||||
},
|
||||
|
||||
typeLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
dropdownIcon: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
dropdownContent: {
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 2,
|
||||
elevation: 3, // Niedriger Elevation-Wert
|
||||
},
|
||||
typeList: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
typeItem: {
|
||||
padding: 8,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255, 255, 255, 0.12)', // white/12
|
||||
},
|
||||
typeItemContent: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
typeItemHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, Modal, FlatList, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { ThemedButton } from '~/components/ui/ThemedButton';
|
||||
|
||||
export type DocumentType = 'original' | 'generated' | 'context' | 'prompt';
|
||||
|
||||
interface DocumentTypeSelectorProps {
|
||||
currentType: DocumentType;
|
||||
onTypeChange: (type: DocumentType) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TypeOption {
|
||||
value: DocumentType;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const DocumentTypeSelector: React.FC<DocumentTypeSelectorProps> = ({
|
||||
currentType,
|
||||
onTypeChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
|
||||
const typeOptions: TypeOption[] = [
|
||||
{
|
||||
value: 'original',
|
||||
label: 'Original',
|
||||
icon: 'document-text-outline',
|
||||
description: 'Importierte oder manuell erstellte Originaltexte',
|
||||
},
|
||||
{
|
||||
value: 'generated',
|
||||
label: 'Generiert',
|
||||
icon: 'sparkles-outline',
|
||||
description: 'KI-generierte neue Texte basierend auf Originaldokumenten',
|
||||
},
|
||||
{
|
||||
value: 'context',
|
||||
label: 'Kontext',
|
||||
icon: 'information-circle-outline',
|
||||
description: 'Texte, die als Kontext für KI-Anfragen dienen',
|
||||
},
|
||||
{
|
||||
value: 'prompt',
|
||||
label: 'Prompt',
|
||||
icon: 'chatbubble-outline',
|
||||
description: 'Prompts für KI-Modelle',
|
||||
},
|
||||
];
|
||||
|
||||
const currentTypeOption =
|
||||
typeOptions.find((option) => option.value === currentType) || typeOptions[0];
|
||||
|
||||
const handleTypeSelect = (type: DocumentType) => {
|
||||
onTypeChange(type);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
const renderTypeItem = ({ item }: { item: TypeOption }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.typeItem,
|
||||
{ backgroundColor: isDark ? '#1f2937' : '#ffffff' },
|
||||
currentType === item.value && {
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
},
|
||||
]}
|
||||
onPress={() => handleTypeSelect(item.value)}
|
||||
>
|
||||
<View style={styles.typeItemContent}>
|
||||
<View style={styles.typeItemHeader}>
|
||||
<Ionicons name={item.icon as any} size={20} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
<Text style={[styles.typeItemLabel, { color: isDark ? '#f9fafb' : '#111827' }]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.typeItemDescription, { color: isDark ? '#9ca3af' : '#6b7280' }]}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemedButton
|
||||
title="Dokumenttyp"
|
||||
onPress={() => setModalVisible(true)}
|
||||
variant="secondary"
|
||||
iconName={currentTypeOption.icon as any}
|
||||
tooltip={`Dokumenttyp: ${currentTypeOption.label}`}
|
||||
disabled={disabled}
|
||||
iconOnly={true}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
|
||||
{/* Modal für mobile Geräte */}
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setModalVisible(false)}
|
||||
>
|
||||
<View style={[styles.modalContent, { backgroundColor: isDark ? '#1f2937' : '#ffffff' }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: isDark ? '#f9fafb' : '#111827' }]}>
|
||||
Dokumenttyp auswählen
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||
<Ionicons name="close" size={24} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
data={typeOptions}
|
||||
renderItem={renderTypeItem}
|
||||
keyExtractor={(item) => item.value}
|
||||
style={styles.typeList}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
typeList: {
|
||||
maxHeight: 300,
|
||||
},
|
||||
typeItem: {
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
typeItemContent: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
typeItemHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
typeItemLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginLeft: 8,
|
||||
},
|
||||
typeItemDescription: {
|
||||
fontSize: 14,
|
||||
marginLeft: 28,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, Animated } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SaveState } from '~/types/document';
|
||||
import { useTheme } from '~/utils/theme';
|
||||
|
||||
interface SaveStateIndicatorProps {
|
||||
saveState: SaveState;
|
||||
lastSavedAt: Date | null;
|
||||
hasUnsavedChanges: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const SaveStateIndicator: React.FC<SaveStateIndicatorProps> = ({
|
||||
saveState,
|
||||
lastSavedAt,
|
||||
hasUnsavedChanges,
|
||||
error,
|
||||
}) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
// Animation values
|
||||
const fadeAnim = useState(new Animated.Value(0))[0];
|
||||
const scaleAnim = useState(new Animated.Value(0.8))[0];
|
||||
|
||||
// Animate in when save state changes
|
||||
useEffect(() => {
|
||||
if (saveState !== 'idle') {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 8,
|
||||
tension: 40,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
// Auto-hide after showing saved state
|
||||
if (saveState === 'saved') {
|
||||
setTimeout(() => {
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}, [saveState, fadeAnim, scaleAnim]);
|
||||
|
||||
const getContent = () => {
|
||||
switch (saveState) {
|
||||
case 'saving':
|
||||
return {
|
||||
icon: 'cloud-upload-outline' as const,
|
||||
text: 'Speichert...',
|
||||
color: isDark ? '#60a5fa' : '#3b82f6',
|
||||
};
|
||||
case 'saved':
|
||||
return {
|
||||
icon: 'checkmark-circle' as const,
|
||||
text: 'Gespeichert',
|
||||
color: isDark ? '#34d399' : '#10b981',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
icon: 'alert-circle' as const,
|
||||
text: error || 'Fehler beim Speichern',
|
||||
color: isDark ? '#f87171' : '#ef4444',
|
||||
};
|
||||
default:
|
||||
if (hasUnsavedChanges) {
|
||||
return {
|
||||
icon: 'pencil' as const,
|
||||
text: 'Nicht gespeichert',
|
||||
color: isDark ? '#fbbf24' : '#f59e0b',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const content = getContent();
|
||||
|
||||
if (!content && saveState === 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: isDark ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.5)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{content && (
|
||||
<>
|
||||
<Ionicons
|
||||
name={content.icon}
|
||||
size={16}
|
||||
color={content.color}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: content.color,
|
||||
}}
|
||||
>
|
||||
{content.text}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
// Minimale Version für die Header-Leiste
|
||||
export const SaveStateIndicatorMinimal: React.FC<SaveStateIndicatorProps> = ({
|
||||
saveState,
|
||||
hasUnsavedChanges,
|
||||
}) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
if (saveState === 'saving') {
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 12 }}>
|
||||
<Ionicons
|
||||
name="cloud-upload-outline"
|
||||
size={16}
|
||||
color={isDark ? '#60a5fa' : '#3b82f6'}
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
<Text style={{ fontSize: 12, color: isDark ? '#9ca3af' : '#6b7280' }}>Speichert...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 12 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: isDark ? '#fbbf24' : '#f59e0b',
|
||||
marginRight: 6,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 12, color: isDark ? '#9ca3af' : '#6b7280' }}>
|
||||
Nicht gespeichert
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useTheme } from '~/utils/theme';
|
||||
import { getDocumentVersions, getAdjacentDocumentVersion } from '~/services/supabaseService';
|
||||
|
||||
type VersionNavigatorProps = {
|
||||
documentId: string;
|
||||
onVersionChange: (newDocumentId: string) => void;
|
||||
};
|
||||
|
||||
export const VersionNavigator: React.FC<VersionNavigatorProps> = ({
|
||||
documentId,
|
||||
onVersionChange,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<{
|
||||
currentVersion: number;
|
||||
totalVersions: number;
|
||||
isOriginal: boolean;
|
||||
} | null>(null);
|
||||
const { mode, colors } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
useEffect(() => {
|
||||
loadVersionInfo();
|
||||
}, [documentId]);
|
||||
|
||||
const loadVersionInfo = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data: versions, error } = await getDocumentVersions(documentId);
|
||||
|
||||
if (error) {
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!versions || versions.length === 0) {
|
||||
setVersionInfo(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finde den Index des aktuellen Dokuments
|
||||
const currentIndex = versions.findIndex((doc) => doc.id === documentId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
setError('Aktuelles Dokument nicht in Versionen gefunden');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bestimme, ob es sich um das Original handelt
|
||||
const isOriginal = currentIndex === 0;
|
||||
|
||||
setVersionInfo({
|
||||
currentVersion: currentIndex,
|
||||
totalVersions: versions.length,
|
||||
isOriginal,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Versionsinformationen:', error);
|
||||
setError('Fehler beim Laden der Versionsinformationen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigateVersion = async (direction: 'next' | 'previous') => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { data: newVersionId, error } = await getAdjacentDocumentVersion(documentId, direction);
|
||||
|
||||
if (error || !newVersionId) {
|
||||
console.log(`Keine ${direction === 'next' ? 'neuere' : 'ältere'} Version verfügbar`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigiere zur neuen Version
|
||||
onVersionChange(newVersionId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Fehler beim Navigieren zur ${direction === 'next' ? 'nächsten' : 'vorherigen'} Version:`,
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Wenn keine Versionen vorhanden sind oder nur eine Version existiert, zeige nichts an
|
||||
if (!versionInfo || (versionInfo.totalVersions <= 1 && versionInfo.isOriginal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.navButton,
|
||||
{ backgroundColor: isDark ? colors.gray[700] : colors.gray[200] },
|
||||
versionInfo.currentVersion === 0 && styles.disabledButton,
|
||||
]}
|
||||
onPress={() => handleNavigateVersion('previous')}
|
||||
disabled={versionInfo.currentVersion === 0 || loading}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back-outline"
|
||||
size={18}
|
||||
color={
|
||||
versionInfo.currentVersion === 0
|
||||
? isDark
|
||||
? colors.gray[500]
|
||||
: colors.gray[400]
|
||||
: isDark
|
||||
? colors.gray[300]
|
||||
: colors.gray[700]
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.versionInfo}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={isDark ? colors.gray[300] : colors.gray[700]} />
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
color: isDark ? colors.gray[300] : colors.gray[700],
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{versionInfo.isOriginal
|
||||
? 'Original'
|
||||
: `Version ${versionInfo.currentVersion}/${versionInfo.totalVersions - 1}`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.navButton,
|
||||
{ backgroundColor: isDark ? colors.gray[700] : colors.gray[200] },
|
||||
versionInfo.currentVersion === versionInfo.totalVersions - 1 && styles.disabledButton,
|
||||
]}
|
||||
onPress={() => handleNavigateVersion('next')}
|
||||
disabled={versionInfo.currentVersion === versionInfo.totalVersions - 1 || loading}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-forward-outline"
|
||||
size={18}
|
||||
color={
|
||||
versionInfo.currentVersion === versionInfo.totalVersions - 1
|
||||
? isDark
|
||||
? colors.gray[500]
|
||||
: colors.gray[400]
|
||||
: isDark
|
||||
? colors.gray[300]
|
||||
: colors.gray[700]
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
navButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
versionInfo: {
|
||||
marginHorizontal: 8,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
46
apps/context/apps/mobile/components/functional/SearchBar.tsx
Normal file
46
apps/context/apps/mobile/components/functional/SearchBar.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useState } from 'react';
|
||||
import { View, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
type SearchBarProps = {
|
||||
placeholder?: string;
|
||||
onSearch: (query: string) => void;
|
||||
initialValue?: string;
|
||||
};
|
||||
|
||||
export const SearchBar = ({
|
||||
placeholder = 'Suchen...',
|
||||
onSearch,
|
||||
initialValue = '',
|
||||
}: SearchBarProps) => {
|
||||
const [query, setQuery] = useState(initialValue);
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('');
|
||||
onSearch('');
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSearch(query);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 mb-4">
|
||||
<Ionicons name="search" size={20} color="#9CA3AF" />
|
||||
<TextInput
|
||||
className="flex-1 ml-2 text-gray-900 dark:text-white"
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onSubmitEditing={handleSubmit}
|
||||
returnKeyType="search"
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<TouchableOpacity onPress={handleClear}>
|
||||
<Ionicons name="close-circle" size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Input } from '../ui/Input';
|
||||
import { Button } from '../Button';
|
||||
import { testSupabaseConnection, testSupabaseAuth, fetchAllSpaces } from '../../utils/supabaseTest';
|
||||
|
||||
export const SupabaseConnectionTest = () => {
|
||||
const [connectionResult, setConnectionResult] = useState<any>(null);
|
||||
const [authResult, setAuthResult] = useState<any>(null);
|
||||
const [spacesResult, setSpacesResult] = useState<any>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const testConnection = async () => {
|
||||
setLoading(true);
|
||||
setConnectionResult(null);
|
||||
|
||||
try {
|
||||
const result = await testSupabaseConnection();
|
||||
setConnectionResult(result);
|
||||
} catch (error: any) {
|
||||
setConnectionResult({
|
||||
success: false,
|
||||
message: `Fehler: ${error.message}`,
|
||||
error,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testAuth = async () => {
|
||||
if (!email || !password) {
|
||||
setAuthResult({
|
||||
success: false,
|
||||
message: 'Bitte E-Mail und Passwort eingeben',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setAuthResult(null);
|
||||
|
||||
try {
|
||||
const result = await testSupabaseAuth(email, password);
|
||||
setAuthResult(result);
|
||||
} catch (error: any) {
|
||||
setAuthResult({
|
||||
success: false,
|
||||
message: `Fehler: ${error.message}`,
|
||||
error,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSpaces = async () => {
|
||||
setLoading(true);
|
||||
setSpacesResult(null);
|
||||
|
||||
try {
|
||||
const result = await fetchAllSpaces();
|
||||
setSpacesResult(result);
|
||||
} catch (error: any) {
|
||||
setSpacesResult({
|
||||
success: false,
|
||||
message: `Fehler: ${error.message}`,
|
||||
error,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<Text variant="h2" className="mb-4">
|
||||
Supabase-Verbindungstest
|
||||
</Text>
|
||||
|
||||
<View className="mb-6">
|
||||
<Button title="Verbindung testen" onPress={testConnection} disabled={loading} />
|
||||
|
||||
{loading && connectionResult === null && (
|
||||
<View className="mt-2 items-center">
|
||||
<ActivityIndicator size="small" color="#6366f1" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{connectionResult && (
|
||||
<View
|
||||
className="mt-2 p-3 rounded-md"
|
||||
style={{
|
||||
backgroundColor: connectionResult.success ? '#dcfce7' : '#fee2e2',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className={connectionResult.success ? 'text-green-700' : 'text-red-700'}
|
||||
>
|
||||
{connectionResult.message}
|
||||
</Text>
|
||||
{connectionResult.data && (
|
||||
<Text variant="caption" className="mt-1">
|
||||
Daten: {JSON.stringify(connectionResult.data)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="mb-6">
|
||||
<Text variant="h3" className="mb-2">
|
||||
Authentifizierung testen
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder="E-Mail"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Passwort"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<Button title="Anmelden" onPress={testAuth} disabled={loading} />
|
||||
|
||||
{loading && authResult === null && (
|
||||
<View className="mt-2 items-center">
|
||||
<ActivityIndicator size="small" color="#6366f1" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{authResult && (
|
||||
<View
|
||||
className="mt-2 p-3 rounded-md"
|
||||
style={{
|
||||
backgroundColor: authResult.success ? '#dcfce7' : '#fee2e2',
|
||||
}}
|
||||
>
|
||||
<Text variant="body" className={authResult.success ? 'text-green-700' : 'text-red-700'}>
|
||||
{authResult.message}
|
||||
</Text>
|
||||
{authResult.user && (
|
||||
<Text variant="caption" className="mt-1">
|
||||
Benutzer-ID: {authResult.user.id}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Button title="Spaces abrufen" onPress={getSpaces} disabled={loading} />
|
||||
|
||||
{loading && spacesResult === null && (
|
||||
<View className="mt-2 items-center">
|
||||
<ActivityIndicator size="small" color="#6366f1" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{spacesResult && (
|
||||
<View
|
||||
className="mt-2 p-3 rounded-md"
|
||||
style={{
|
||||
backgroundColor: spacesResult.success ? '#dcfce7' : '#fee2e2',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className={spacesResult.success ? 'text-green-700' : 'text-red-700'}
|
||||
>
|
||||
{spacesResult.message}
|
||||
</Text>
|
||||
{spacesResult.spaces && (
|
||||
<View className="mt-2">
|
||||
<Text variant="body" className="font-bold">
|
||||
Spaces:
|
||||
</Text>
|
||||
{spacesResult.spaces.map((space: any, index: number) => (
|
||||
<View
|
||||
key={space.id || index}
|
||||
className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded"
|
||||
>
|
||||
<Text variant="body">{space.name}</Text>
|
||||
{space.description && <Text variant="caption">{space.description}</Text>}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
97
apps/context/apps/mobile/components/layout/AppLayout.tsx
Normal file
97
apps/context/apps/mobile/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Breadcrumbs } from '../navigation/Breadcrumbs';
|
||||
import { useSegments, useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getSpaceById } from '~/services/supabaseService';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const segments = useSegments();
|
||||
const [spaceName, setSpaceName] = useState<string>('');
|
||||
const [documentTitle, setDocumentTitle] = useState<string>('');
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<Array<{ label: string; href?: string }>>(
|
||||
[]
|
||||
);
|
||||
|
||||
// Funktion zum Laden des Space-Namens
|
||||
const loadSpaceName = async (spaceId: string) => {
|
||||
try {
|
||||
const space = await getSpaceById(spaceId);
|
||||
if (space) {
|
||||
setSpaceName(space.name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden des Space-Namens:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Aktualisiere die Breadcrumbs basierend auf dem aktuellen Pfad
|
||||
useEffect(() => {
|
||||
const updateBreadcrumbs = async () => {
|
||||
const items: Array<{ label: string; href?: string }> = [];
|
||||
|
||||
// Immer mit Home/Spaces beginnen
|
||||
items.push({ label: 'Spaces', href: '/' });
|
||||
|
||||
// Convert segments to a string array to avoid tuple type issues
|
||||
const segmentArray = segments as string[];
|
||||
|
||||
// Wenn wir in einem Space sind
|
||||
if (segmentArray.length > 1 && segmentArray[0] === 'spaces') {
|
||||
const spaceId = segmentArray[1];
|
||||
|
||||
// Lade den Space-Namen, wenn wir ihn noch nicht haben
|
||||
if (!spaceName && spaceId) {
|
||||
await loadSpaceName(spaceId);
|
||||
}
|
||||
|
||||
// Füge den Space zur Breadcrumb hinzu
|
||||
if (spaceName) {
|
||||
items.push({ label: spaceName, href: `/spaces/${spaceId}` });
|
||||
} else {
|
||||
items.push({ label: 'Space', href: `/spaces/${spaceId}` });
|
||||
}
|
||||
|
||||
// Wenn wir in der Dokumentenansicht sind
|
||||
if (segmentArray.length > 3 && segmentArray[2] === 'documents') {
|
||||
const documentId = segmentArray[3];
|
||||
|
||||
if (documentId && documentId === 'new') {
|
||||
items.push({ label: 'Neues Dokument' });
|
||||
} else if (documentTitle) {
|
||||
items.push({ label: documentTitle });
|
||||
} else {
|
||||
items.push({ label: 'Dokument' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBreadcrumbItems(items);
|
||||
};
|
||||
|
||||
updateBreadcrumbs();
|
||||
}, [segments, spaceName, documentTitle]);
|
||||
|
||||
// Setze den Dokumenttitel, wenn er von außen gesetzt wird
|
||||
const setCurrentDocumentTitle = (title: string) => {
|
||||
setDocumentTitle(title);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
{/* Breadcrumb-Navigation */}
|
||||
{breadcrumbItems.length > 1 && (
|
||||
<View className="px-4 py-2 bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
||||
<Breadcrumbs items={breadcrumbItems} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Hauptinhalt */}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
37
apps/context/apps/mobile/components/layout/EmptyState.tsx
Normal file
37
apps/context/apps/mobile/components/layout/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { View, ViewProps } from 'react-native';
|
||||
import { ReactNode } from 'react';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Button } from '../Button';
|
||||
|
||||
type EmptyStateProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
} & ViewProps;
|
||||
|
||||
export const EmptyState = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actionLabel,
|
||||
onAction,
|
||||
className,
|
||||
...props
|
||||
}: EmptyStateProps) => {
|
||||
return (
|
||||
<View className={`items-center justify-center py-12 px-4 ${className || ''}`} {...props}>
|
||||
{icon && <View className="mb-4">{icon}</View>}
|
||||
<Text variant="h3" className="text-center mb-2">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text variant="body" className="text-center text-gray-500 mb-6 max-w-xs">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{actionLabel && onAction && <Button title={actionLabel} onPress={onAction} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
76
apps/context/apps/mobile/components/layout/Screen.tsx
Normal file
76
apps/context/apps/mobile/components/layout/Screen.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
View,
|
||||
ViewProps,
|
||||
ScrollViewProps,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { ReactNode } from 'react';
|
||||
import { Text } from '../ui/Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
type ScreenProps = {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
scrollable?: boolean;
|
||||
padded?: boolean;
|
||||
} & (ScrollViewProps | ViewProps);
|
||||
|
||||
export const Screen = ({
|
||||
title,
|
||||
children,
|
||||
scrollable = true,
|
||||
padded = true,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: ScreenProps) => {
|
||||
const Content = scrollable ? ScrollView : View;
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: isDark ? '#111827' : '#f9fafb' }]}>
|
||||
{title && (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? '#374151' : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#f9fafb' : '#111827',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Content
|
||||
style={[styles.content, padded && styles.padded, style]}
|
||||
contentContainerStyle={scrollable && padded ? { paddingBottom: 20 } : undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Content>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
padded: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
});
|
||||
175
apps/context/apps/mobile/components/mentions/DocumentPreview.tsx
Normal file
175
apps/context/apps/mobile/components/mentions/DocumentPreview.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView } from 'react-native';
|
||||
import { Document } from '~/services/supabaseService';
|
||||
import { useTheme } from '~/utils/theme';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
interface DocumentPreviewProps {
|
||||
document: Document | null;
|
||||
position?: { top: number; left: number };
|
||||
visible?: boolean;
|
||||
maxHeight?: number;
|
||||
maxWidth?: number;
|
||||
inline?: boolean; // Wenn true, wird die Vorschau inline angezeigt (nicht absolut positioniert)
|
||||
}
|
||||
|
||||
export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
||||
document,
|
||||
position,
|
||||
visible = true,
|
||||
maxHeight = 300,
|
||||
maxWidth = 400,
|
||||
inline = false,
|
||||
}) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
if ((!visible && !inline) || !document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get document type color
|
||||
const getTypeColor = (type: 'text' | 'context' | 'prompt'): string => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return isDark ? '#818cf8' : '#4f46e5'; // Indigo
|
||||
case 'context':
|
||||
return isDark ? '#34d399' : '#16a34a'; // Green
|
||||
case 'prompt':
|
||||
return isDark ? '#fbbf24' : '#d97706'; // Amber
|
||||
default:
|
||||
return isDark ? '#818cf8' : '#4f46e5'; // Default to indigo
|
||||
}
|
||||
};
|
||||
|
||||
// Truncate content for preview
|
||||
const previewContent = document.content
|
||||
? document.content.substring(0, 500) + (document.content.length > 500 ? '...' : '')
|
||||
: 'Kein Inhalt vorhanden';
|
||||
|
||||
// Wir verwenden den Originalinhalt, da die Markdown-Komponente die Links verarbeitet
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
inline ? styles.inlineContainer : styles.container,
|
||||
position && !inline
|
||||
? {
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
}
|
||||
: {},
|
||||
{
|
||||
maxHeight: inline ? undefined : maxHeight,
|
||||
maxWidth: inline ? undefined : maxWidth,
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
}}
|
||||
>
|
||||
{document.title}
|
||||
</Text>
|
||||
<View style={[styles.typeTag, { backgroundColor: getTypeColor(document.type) + '20' }]}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: getTypeColor(document.type),
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{document.type === 'text' ? 'Text' : document.type === 'context' ? 'Kontext' : 'Prompt'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView style={styles.content} contentContainerStyle={{ paddingBottom: 8 }}>
|
||||
<Markdown
|
||||
style={{
|
||||
body: { fontSize: 14, color: isDark ? '#f3f4f6' : '#1f2937' },
|
||||
paragraph: { marginVertical: 8 },
|
||||
heading1: {
|
||||
fontSize: 18,
|
||||
marginVertical: 8,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 16,
|
||||
marginVertical: 6,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 14,
|
||||
marginVertical: 4,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
padding: 2,
|
||||
borderRadius: 3,
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
link: { color: isDark ? '#60a5fa' : '#2563eb' }, // Blau für Links
|
||||
}}
|
||||
rules={{
|
||||
image: () => null, // Bilder nicht rendern
|
||||
}}
|
||||
>
|
||||
{previewContent}
|
||||
</Markdown>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
zIndex: 1000,
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 3,
|
||||
},
|
||||
inlineContainer: {
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginVertical: 8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
paddingBottom: 8,
|
||||
},
|
||||
typeTag: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
maxHeight: 200, // Begrenzte Höhe für Scrollbarkeit
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useState, useEffect, forwardRef, ForwardRefRenderFunction } from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, TextInputProps } from 'react-native';
|
||||
import { useTheme } from '~/utils/theme';
|
||||
import { MENTION_REGEX } from '~/utils/mentionProcessor';
|
||||
|
||||
interface HighlightedMentionInputProps extends TextInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein TextInput, der @-Erwähnungen hervorhebt, indem er sie als formatierte Komponenten anzeigt
|
||||
*/
|
||||
const HighlightedMentionInputBase: ForwardRefRenderFunction<
|
||||
TextInput,
|
||||
HighlightedMentionInputProps
|
||||
> = ({ value, onChangeText, style, ...props }, ref) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
// Teile den Text in normale Textabschnitte und @-Erwähnungen auf
|
||||
const renderHighlightedText = () => {
|
||||
if (!value) return null;
|
||||
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
// Regex-Kopie erstellen, um den lastIndex zurückzusetzen
|
||||
const regex = new RegExp(MENTION_REGEX);
|
||||
|
||||
while ((match = regex.exec(value)) !== null) {
|
||||
// Text vor der @-Erwähnung
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(
|
||||
<Text key={`text-${lastIndex}`} style={styles.plainText}>
|
||||
{value.substring(lastIndex, match.index)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Die @-Erwähnung selbst
|
||||
const [fullMatch, title, id] = match;
|
||||
parts.push(
|
||||
<Text
|
||||
key={`mention-${match.index}`}
|
||||
style={[styles.mention, { color: isDark ? '#60a5fa' : '#2563eb' }]}
|
||||
>
|
||||
@{title}
|
||||
</Text>
|
||||
);
|
||||
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
}
|
||||
|
||||
// Text nach der letzten @-Erwähnung
|
||||
if (lastIndex < value.length) {
|
||||
parts.push(
|
||||
<Text key={`text-${lastIndex}`} style={styles.plainText}>
|
||||
{value.substring(lastIndex)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Hervorgehobener Text (nur zur Anzeige) */}
|
||||
<View style={[styles.highlightLayer]}>{renderHighlightedText()}</View>
|
||||
|
||||
{/* Tatsächliches TextInput (transparent für Bearbeitung) */}
|
||||
<TextInput
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
style={[styles.input, style]}
|
||||
multiline
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
highlightLayer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'none',
|
||||
flexDirection: 'row' as const,
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
input: {
|
||||
color: 'transparent',
|
||||
// caretColor ist nur für Web verfügbar, daher entfernen wir es
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
plainText: {
|
||||
color: 'transparent',
|
||||
},
|
||||
mention: {
|
||||
fontWeight: '500',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
|
||||
export const HighlightedMentionInput = forwardRef(HighlightedMentionInputBase);
|
||||
161
apps/context/apps/mobile/components/mentions/MentionDropdown.tsx
Normal file
161
apps/context/apps/mobile/components/mentions/MentionDropdown.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Document } from '~/services/supabaseService';
|
||||
import { useTheme } from '~/utils/theme';
|
||||
|
||||
interface MentionDropdownProps {
|
||||
documents: Document[];
|
||||
onSelectDocument: (document: Document) => void;
|
||||
position?: { top: number; left: number }; // Optional, wir verwenden jetzt eine feste Position
|
||||
visible: boolean;
|
||||
maxHeight?: number;
|
||||
fullWidth?: boolean; // Ob das Dropdown die volle Breite einnehmen soll
|
||||
}
|
||||
|
||||
export const MentionDropdown: React.FC<MentionDropdownProps> = ({
|
||||
documents,
|
||||
onSelectDocument,
|
||||
position,
|
||||
visible,
|
||||
maxHeight = 200,
|
||||
fullWidth = false,
|
||||
}) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
const dropdownRef = useRef<View>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(visible);
|
||||
|
||||
// Wenn visible sich ändert, aktualisiere isVisible
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setIsVisible(true);
|
||||
} else if (!isHovered) {
|
||||
// Nur ausblenden, wenn explizit angefordert und nicht mit der Maus darüber
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [visible, isHovered]);
|
||||
|
||||
// Debug-Ausgabe
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
console.log('MentionDropdown ist sichtbar mit', documents.length, 'Dokumenten');
|
||||
console.log('Position:', position);
|
||||
}
|
||||
}, [isVisible, documents, position]);
|
||||
|
||||
// Kein automatisches Ausblenden mehr
|
||||
// Die Liste bleibt sichtbar, bis der Benutzer eine Auswahl trifft oder das Textfeld verlässt
|
||||
|
||||
if (!isVisible || documents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bildschirmbreite für fullWidth-Option
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={dropdownRef}
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
// Wenn position vorhanden ist, verwende sie, sonst feste Position
|
||||
top: position ? position.top : 100,
|
||||
left: position ? position.left : 20,
|
||||
// Wenn fullWidth, dann volle Breite, sonst 250px
|
||||
width: fullWidth ? screenWidth : 250,
|
||||
maxHeight,
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
// Schatten für bessere Sichtbarkeit
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 3,
|
||||
elevation: 5,
|
||||
},
|
||||
]}
|
||||
{...(Platform.OS === 'web'
|
||||
? {
|
||||
onMouseEnter: () => setIsHovered(true),
|
||||
onMouseLeave: () => setIsHovered(false),
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<ScrollView style={styles.scrollView}>
|
||||
{documents.map((doc) => (
|
||||
<TouchableOpacity
|
||||
key={doc.id}
|
||||
style={[styles.documentItem, { backgroundColor: isDark ? '#1f2937' : '#ffffff' }]}
|
||||
onPress={() => {
|
||||
// Dokument auswählen und Dropdown schließen
|
||||
onSelectDocument(doc);
|
||||
// Dropdown erst schließen, nachdem die Auswahl verarbeitet wurde
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, 200);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
}}
|
||||
>
|
||||
{doc.title}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{getDocumentTypeLabel(doc.type)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const getDocumentTypeLabel = (type: 'text' | 'context' | 'prompt'): string => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return 'Text';
|
||||
case 'context':
|
||||
return 'Kontext';
|
||||
case 'prompt':
|
||||
return 'Prompt';
|
||||
default:
|
||||
return 'Dokument';
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
zIndex: 9999, // Höherer z-Index, damit es über allem schwebt
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
documentItem: {
|
||||
padding: 10,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useTheme } from '~/utils/theme';
|
||||
import { MENTION_REGEX } from '~/utils/mentionProcessor';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
interface MentionHighlighterProps {
|
||||
text: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Komponente, die Text mit hervorgehobenen @-Erwähnungen anzeigt
|
||||
*/
|
||||
export const MentionHighlighter: React.FC<MentionHighlighterProps> = ({ text, spaceId }) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
const router = useRouter();
|
||||
|
||||
// Wenn kein Text vorhanden ist, nichts anzeigen
|
||||
if (!text) return null;
|
||||
|
||||
// Text in normale Textabschnitte und @-Erwähnungen aufteilen
|
||||
const renderHighlightedText = () => {
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
// Regex-Kopie erstellen, um den lastIndex zurückzusetzen
|
||||
const regex = new RegExp(MENTION_REGEX);
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Text vor der @-Erwähnung
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(
|
||||
<Text key={`text-${lastIndex}`} style={styles.plainText}>
|
||||
{text.substring(lastIndex, match.index)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Die @-Erwähnung selbst
|
||||
const [fullMatch, title, id] = match;
|
||||
parts.push(
|
||||
<TouchableOpacity
|
||||
key={`mention-${match.index}`}
|
||||
onPress={() => {
|
||||
// Zum referenzierten Dokument navigieren
|
||||
if (spaceId) {
|
||||
router.push(`/spaces/${spaceId}/documents/${id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.mention, { color: isDark ? '#60a5fa' : '#2563eb' }]}>@{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
}
|
||||
|
||||
// Text nach der letzten @-Erwähnung
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(
|
||||
<Text key={`text-${lastIndex}`} style={styles.plainText}>
|
||||
{text.substring(lastIndex)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.textContainer}>{renderHighlightedText()}</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
textContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
plainText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
mention: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
477
apps/context/apps/mobile/components/mentions/MentionRenderer.tsx
Normal file
477
apps/context/apps/mobile/components/mentions/MentionRenderer.tsx
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Text, TouchableOpacity, View, Platform, Modal, ScrollView, Pressable } from 'react-native';
|
||||
import {
|
||||
Document,
|
||||
getDocumentById,
|
||||
getDocumentByShortId,
|
||||
getDocuments,
|
||||
} from '~/services/supabaseService';
|
||||
import { DocumentPreview } from './DocumentPreview';
|
||||
import { useTheme } from '~/utils/theme';
|
||||
import { useRouter } from 'expo-router';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
interface MentionRendererProps {
|
||||
documentId?: string; // Optional für das neue Format
|
||||
documentTitle: string;
|
||||
spaceId?: string; // Optional space ID für Navigation
|
||||
children?: React.ReactNode; // Für den anzuzeigenden Text
|
||||
}
|
||||
|
||||
export const MentionRenderer: React.FC<MentionRendererProps> = ({
|
||||
documentId,
|
||||
documentTitle,
|
||||
spaceId,
|
||||
children,
|
||||
}) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
const router = useRouter();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [document, setDocument] = useState<Document | null>(null);
|
||||
const [previewPosition, setPreviewPosition] = useState({ top: 0, left: 0 });
|
||||
const mentionRef = useRef<View>(null);
|
||||
const previewTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load document on mount if documentId is provided
|
||||
useEffect(() => {
|
||||
const loadDocument = async () => {
|
||||
if (!documentId) {
|
||||
// Wenn keine ID vorhanden ist, versuche das Dokument anhand des Titels zu finden
|
||||
// Dies ist für das neue Format [[Dokumenttitel]] ohne ID
|
||||
try {
|
||||
// Hier müsste eine Funktion implementiert werden, die nach Titel sucht
|
||||
// Für jetzt lassen wir es leer, da wir die Vorschau ohne Dokument anzeigen können
|
||||
console.log('Suche nach Dokument mit Titel:', documentTitle);
|
||||
} catch (error) {
|
||||
console.error('Error searching for document by title:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let doc;
|
||||
|
||||
// Prüfe, ob es sich um eine UUID oder eine kurze ID handelt
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
documentId
|
||||
);
|
||||
|
||||
if (isUuid) {
|
||||
// Wenn es eine UUID ist, verwende getDocumentById
|
||||
doc = await getDocumentById(documentId);
|
||||
} else {
|
||||
// Wenn es eine kurze ID ist, verwende die neue getDocumentByShortId-Funktion
|
||||
doc = await getDocumentByShortId(documentId);
|
||||
}
|
||||
|
||||
setDocument(doc);
|
||||
} catch (error) {
|
||||
console.error('Error fetching document:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocument();
|
||||
}, [documentId, documentTitle]);
|
||||
|
||||
// Einfache Funktionen für Hover-Effekte
|
||||
const handleMouseEnter = () => {
|
||||
if (Platform.OS !== 'web') return;
|
||||
|
||||
console.log('Mouse enter event triggered');
|
||||
|
||||
// Direkte DOM-Manipulation für Web
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
// Verwende direkten DOM-Zugriff für Web
|
||||
const element = mentionRef.current as any;
|
||||
if (element) {
|
||||
// Verwende getBoundingClientRect für präzisere Positionierung
|
||||
if (element.getBoundingClientRect) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scrollTop = window.scrollY || 0;
|
||||
const scrollLeft = window.scrollX || 0;
|
||||
|
||||
// Setze Position relativ zum Viewport
|
||||
const newPosition = {
|
||||
top: rect.bottom + scrollTop,
|
||||
left: rect.left + scrollLeft,
|
||||
};
|
||||
|
||||
console.log('Preview position calculated:', newPosition);
|
||||
setPreviewPosition(newPosition);
|
||||
} else {
|
||||
// Fallback für React Native Web
|
||||
console.log('Using React Native measure method');
|
||||
element.measure?.(
|
||||
(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
pageX: number,
|
||||
pageY: number
|
||||
) => {
|
||||
const newPosition = {
|
||||
top: pageY + height + 5,
|
||||
left: pageX,
|
||||
};
|
||||
console.log('Preview position from measure:', newPosition);
|
||||
setPreviewPosition(newPosition);
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('Reference to element is null');
|
||||
}
|
||||
|
||||
// Sofort anzeigen
|
||||
console.log('Setting showPreview to true');
|
||||
setShowPreview(true);
|
||||
} catch (error) {
|
||||
console.error('Error measuring element:', error);
|
||||
// Fallback
|
||||
const newPosition = {
|
||||
top: 100,
|
||||
left: 20,
|
||||
};
|
||||
console.log('Using fallback position:', newPosition);
|
||||
setPreviewPosition(newPosition);
|
||||
setShowPreview(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (Platform.OS !== 'web') return;
|
||||
|
||||
// Kurze Verzögerung vor dem Ausblenden
|
||||
if (previewTimeout.current) {
|
||||
clearTimeout(previewTimeout.current);
|
||||
}
|
||||
|
||||
previewTimeout.current = setTimeout(() => {
|
||||
setShowPreview(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Cleanup-Funktion
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimeout.current) {
|
||||
clearTimeout(previewTimeout.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
ref={mentionRef}
|
||||
onPress={() => {
|
||||
// Zeige Modal-Vorschau beim Klicken an
|
||||
if (document) {
|
||||
setShowModal(true);
|
||||
} else if (documentId) {
|
||||
// Lade das Dokument, wenn es noch nicht geladen wurde
|
||||
getDocumentById(documentId)
|
||||
.then((doc) => {
|
||||
setDocument(doc);
|
||||
setShowModal(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Fehler beim Laden des Dokuments:', error);
|
||||
});
|
||||
} else {
|
||||
// Hier könnte eine Suche nach dem Titel implementiert werden
|
||||
console.log('Dokument mit Titel anzeigen:', documentTitle);
|
||||
// Für jetzt zeigen wir nur den Titel an
|
||||
setShowModal(true);
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
// Bei langem Drücken direkt zum Dokument navigieren
|
||||
if (spaceId && documentId) {
|
||||
router.push(`/spaces/${spaceId}/documents/${documentId}`);
|
||||
}
|
||||
}}
|
||||
delayLongPress={500}
|
||||
// Web-spezifische Hover-Events
|
||||
{...(Platform.OS === 'web'
|
||||
? {
|
||||
onMouseEnter: handleMouseEnter,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: isDark ? '#60a5fa' : '#2563eb', // Blau für Links
|
||||
textDecorationLine: 'underline',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{children || documentTitle}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Document preview (shown on hover/press) - direktes Rendering mit fester Position */}
|
||||
{showPreview && Platform.OS === 'web' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${previewPosition.top}px`,
|
||||
left: `${previewPosition.left}px`,
|
||||
zIndex: 99999, // Sehr hoher z-index
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
maxWidth: '350px',
|
||||
minWidth: '250px',
|
||||
border: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{document ? (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: `1px solid ${isDark ? '#374151' : '#e5e7eb'}`,
|
||||
paddingBottom: '8px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
}}
|
||||
>
|
||||
{document.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor:
|
||||
(document.type === 'context'
|
||||
? '#16a34a'
|
||||
: document.type === 'prompt'
|
||||
? '#d97706'
|
||||
: '#4f46e5') + '20',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
color:
|
||||
document.type === 'context'
|
||||
? '#16a34a'
|
||||
: document.type === 'prompt'
|
||||
? '#d97706'
|
||||
: '#4f46e5',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{document.type === 'text'
|
||||
? 'Text'
|
||||
: document.type === 'context'
|
||||
? 'Kontext'
|
||||
: document.type === 'prompt'
|
||||
? 'Prompt'
|
||||
: 'Dokument'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{document.content ? (
|
||||
<Markdown
|
||||
style={{
|
||||
body: { color: isDark ? '#f3f4f6' : '#1f2937' },
|
||||
paragraph: { marginVertical: 8 },
|
||||
heading1: { fontSize: 18, marginVertical: 8, fontWeight: 'bold' },
|
||||
heading2: { fontSize: 16, marginVertical: 6, fontWeight: 'bold' },
|
||||
heading3: { fontSize: 14, marginVertical: 4, fontWeight: 'bold' },
|
||||
code_inline: {
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
padding: 2,
|
||||
borderRadius: 3,
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: isDark ? '#374151' : '#f3f4f6',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
link: { color: isDark ? '#60a5fa' : '#2563eb' },
|
||||
}}
|
||||
>
|
||||
{document.content.substring(0, 500) +
|
||||
(document.content.length > 500 ? '...' : '')}
|
||||
</Markdown>
|
||||
) : (
|
||||
<Text style={{ fontStyle: 'italic' }}>Kein Inhalt vorhanden</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '8px' }}>
|
||||
<div style={{ color: isDark ? '#d1d5db' : '#4b5563', fontStyle: 'italic' }}>
|
||||
Vorschau wird geladen...
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '14px',
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
}}
|
||||
>
|
||||
Dokument: {documentTitle}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal-Vorschau beim Klicken */}
|
||||
<Modal
|
||||
visible={showModal}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
}}
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowModal(false)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
maxHeight: '80%',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
}}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onTouchEnd={(e) => e.stopPropagation()}
|
||||
>
|
||||
{document ? (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? '#f3f4f6' : '#1f2937',
|
||||
}}
|
||||
>
|
||||
{document.title}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setShowModal(false)}>
|
||||
<Text style={{ fontSize: 16, color: isDark ? '#9ca3af' : '#6b7280' }}>
|
||||
Schließen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={{ maxHeight: '90%' }}>
|
||||
<ScrollView>
|
||||
<View
|
||||
style={{ flexDirection: 'row', justifyContent: 'flex-end', marginTop: 20 }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowModal(false)}
|
||||
style={{
|
||||
backgroundColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
marginRight: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: isDark ? '#f3f4f6' : '#1f2937' }}>Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{spaceId && documentId && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowModal(false);
|
||||
router.push(`/spaces/${spaceId}/documents/${documentId}`);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: isDark ? '#2563eb' : '#3b82f6',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#ffffff' }}>Dokument öffnen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Wenn keine ID vorhanden ist, zeige einen Button zum Suchen nach dem Titel */}
|
||||
{spaceId && !documentId && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setShowModal(false);
|
||||
// Hier könnte eine Suche nach dem Titel implementiert werden
|
||||
// Für jetzt navigieren wir zur Spaces-Seite
|
||||
router.push(
|
||||
`/spaces/${spaceId}?search=${encodeURIComponent(documentTitle)}`
|
||||
);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: isDark ? '#2563eb' : '#3b82f6',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#ffffff' }}>Nach Dokument suchen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ color: isDark ? '#f3f4f6' : '#1f2937' }}>
|
||||
{document.content}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<Text style={{ color: isDark ? '#f3f4f6' : '#1f2937' }}>
|
||||
Dokument wird geladen...
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,531 @@
|
|||
import React, { useState, useRef, useEffect, forwardRef, ForwardRefRenderFunction } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
View,
|
||||
NativeSyntheticEvent,
|
||||
TextInputSelectionChangeEventData,
|
||||
Platform,
|
||||
Dimensions,
|
||||
Text,
|
||||
} from 'react-native';
|
||||
import { Document, getDocuments } from '~/services/supabaseService';
|
||||
import { MentionDropdown } from './MentionDropdown';
|
||||
import { useTheme } from '~/utils/theme';
|
||||
|
||||
interface MentionTextInputProps extends TextInputProps {
|
||||
spaceId: string;
|
||||
onMentionInserted?: (documentId: string, documentTitle: string) => void;
|
||||
}
|
||||
|
||||
const MentionTextInputBase: ForwardRefRenderFunction<TextInput, MentionTextInputProps> = (
|
||||
{ spaceId, value, onChangeText, onMentionInserted, ...props },
|
||||
ref
|
||||
) => {
|
||||
const { mode } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
// Erstelle einen lokalen Ref
|
||||
const localInputRef = useRef<TextInput>(null);
|
||||
|
||||
// Kombiniere den lokalen Ref mit dem übergebenen Ref
|
||||
useEffect(() => {
|
||||
if (ref && localInputRef.current) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(localInputRef.current);
|
||||
} else {
|
||||
ref.current = localInputRef.current;
|
||||
}
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
// State for mention functionality
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [mentionStartIndex, setMentionStartIndex] = useState<number>(-1);
|
||||
const [matchingDocuments, setMatchingDocuments] = useState<Document[]>([]);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [selection, setSelection] = useState<{ start: number; end: number } | undefined>(undefined);
|
||||
const [allDocuments, setAllDocuments] = useState<Document[]>([]);
|
||||
const [debugInfo, setDebugInfo] = useState<string>('');
|
||||
|
||||
// Load all documents from the space
|
||||
useEffect(() => {
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
console.log('Lade Dokumente für Space:', spaceId);
|
||||
const docs = await getDocuments(spaceId);
|
||||
console.log('Anzahl geladener Dokumente:', docs.length);
|
||||
setAllDocuments(docs);
|
||||
} catch (error) {
|
||||
console.error('Error loading documents:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (spaceId) {
|
||||
loadDocuments();
|
||||
} else {
|
||||
console.warn('Kein spaceId vorhanden, kann keine Dokumente laden');
|
||||
}
|
||||
}, [spaceId]);
|
||||
|
||||
// Handle text changes to detect mentions
|
||||
const handleChangeText = (text: string) => {
|
||||
if (onChangeText) {
|
||||
onChangeText(text);
|
||||
}
|
||||
|
||||
// Suche nach [[ und extrahiere den Text danach
|
||||
const bracketIndex = text.lastIndexOf('[[');
|
||||
|
||||
if (bracketIndex >= 0) {
|
||||
// Prüfe, ob nach [[ mindestens 2 Zeichen stehen
|
||||
const afterBracket = text.substring(bracketIndex + 2);
|
||||
setDebugInfo(`[[ gefunden bei ${bracketIndex}, Text danach: "${afterBracket}"`);
|
||||
|
||||
// Prüfe, ob die schließende Klammer bereits vorhanden ist
|
||||
if (!afterBracket.includes(']]')) {
|
||||
if (afterBracket.length >= 1) {
|
||||
// Reduziert auf 1 Zeichen für frühere Anzeige
|
||||
// Extrahiere den Suchbegriff (alles nach [[ bis zum nächsten ]] oder Ende)
|
||||
const searchTerm = afterBracket;
|
||||
|
||||
setMentionStartIndex(bracketIndex);
|
||||
setMentionQuery(searchTerm);
|
||||
|
||||
// Suche nach passenden Dokumenten
|
||||
const filtered = allDocuments.filter((doc) =>
|
||||
doc.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Immer mindestens die ersten 5 Dokumente anzeigen, wenn der Suchbegriff kurz ist
|
||||
let documentsToShow = filtered;
|
||||
if (filtered.length === 0 && searchTerm.length <= 2) {
|
||||
documentsToShow = allDocuments.slice(0, 5);
|
||||
} else {
|
||||
documentsToShow = filtered.slice(0, 10); // Mehr Ergebnisse anzeigen (10 statt 5)
|
||||
}
|
||||
|
||||
setDebugInfo(`Suche nach "${searchTerm}", ${documentsToShow.length} Dokumente angezeigt`);
|
||||
setMatchingDocuments(documentsToShow);
|
||||
setShowDropdown(true); // Immer anzeigen, auch wenn keine Ergebnisse
|
||||
calculateDropdownPosition();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Abwärtskompatibilität: Suche nach @ und extrahiere den Text danach
|
||||
const atIndex = text.lastIndexOf('@');
|
||||
|
||||
if (atIndex >= 0) {
|
||||
// Prüfe, ob nach dem @ mindestens 3 Zeichen stehen
|
||||
const afterAt = text.substring(atIndex + 1);
|
||||
setDebugInfo(`@ gefunden bei ${atIndex}, Text danach: "${afterAt}"`);
|
||||
|
||||
if (afterAt.length >= 3) {
|
||||
// Extrahiere den Suchbegriff (alles nach @ bis zum nächsten Leerzeichen oder Ende)
|
||||
const searchTerm = afterAt.split(/\s/)[0];
|
||||
|
||||
if (searchTerm.length >= 3) {
|
||||
setMentionStartIndex(atIndex);
|
||||
setMentionQuery(searchTerm);
|
||||
|
||||
// Suche nach passenden Dokumenten
|
||||
const filtered = allDocuments.filter((doc) =>
|
||||
doc.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
setDebugInfo(`Suche nach "${searchTerm}", ${filtered.length} Dokumente gefunden`);
|
||||
setMatchingDocuments(filtered.slice(0, 5)); // Limit to 5 results
|
||||
setShowDropdown(filtered.length > 0);
|
||||
calculateDropdownPosition();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn weder [[ noch @ gefunden wurde, Dropdown trotzdem sichtbar lassen
|
||||
// Wir blenden das Dropdown nicht automatisch aus
|
||||
// if (showDropdown) {
|
||||
// cancelMention();
|
||||
// }
|
||||
};
|
||||
|
||||
// Focus handler
|
||||
const handleFocus = () => {
|
||||
// Nichts tun, wenn der Fokus erhalten wird
|
||||
};
|
||||
|
||||
// Blur handler
|
||||
const handleBlur = () => {
|
||||
// Dropdown nicht ausblenden, wenn der Fokus verloren geht
|
||||
// Es bleibt sichtbar, bis der Benutzer eine Auswahl trifft
|
||||
console.log('Textfeld hat Fokus verloren, Dropdown bleibt sichtbar');
|
||||
// Wir rufen cancelMention nicht auf, damit das Dropdown sichtbar bleibt
|
||||
};
|
||||
|
||||
// Start tracking a mention
|
||||
const startMention = (index: number) => {
|
||||
console.log('Starte Mention-Tracking bei Index:', index);
|
||||
setMentionStartIndex(index);
|
||||
setMentionQuery('');
|
||||
calculateDropdownPosition();
|
||||
};
|
||||
|
||||
// Cancel mention mode
|
||||
const cancelMention = () => {
|
||||
// Setze nur die Mention-Daten zurück, aber lasse das Dropdown geöffnet
|
||||
setMentionStartIndex(-1);
|
||||
setMentionQuery('');
|
||||
setDebugInfo('Mention-Modus beendet, Dropdown bleibt geöffnet');
|
||||
// Dropdown bleibt sichtbar, bis der Benutzer eine Auswahl trifft
|
||||
// setShowDropdown(false); // Auskommentiert, damit das Dropdown sichtbar bleibt
|
||||
};
|
||||
|
||||
// Handle selection changes
|
||||
const handleSelectionChange = (e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
||||
setSelection(e.nativeEvent.selection);
|
||||
|
||||
// If we move the cursor away from the mention area, cancel the mention
|
||||
if (
|
||||
mentionQuery !== null &&
|
||||
(e.nativeEvent.selection.start < mentionStartIndex ||
|
||||
e.nativeEvent.selection.start > mentionStartIndex + mentionQuery.length + 1)
|
||||
) {
|
||||
cancelMention();
|
||||
}
|
||||
};
|
||||
|
||||
// Dropdown-Position berechnen, damit es unter dem Cursor erscheint
|
||||
const calculateDropdownPosition = () => {
|
||||
console.log('Berechne Dropdown-Position unter dem Cursor');
|
||||
|
||||
// Versuche, die Position des Cursors zu ermitteln
|
||||
if (localInputRef.current) {
|
||||
try {
|
||||
// Auf Web-Plattformen können wir die Cursor-Position ermitteln
|
||||
if (Platform.OS === 'web') {
|
||||
// Wir müssen auf die native DOM-Methoden zugreifen
|
||||
// @ts-ignore - Wir wissen, dass wir auf Web sind
|
||||
const input = localInputRef.current._reactInternals?.stateNode;
|
||||
|
||||
if (input) {
|
||||
// Ermittle die Cursor-Position im Textfeld
|
||||
const cursorPosition = input.selectionStart;
|
||||
|
||||
// Ermittle die Zeile, in der sich der Cursor befindet
|
||||
const text = value || '';
|
||||
const lines = text.substring(0, cursorPosition).split('\n');
|
||||
const currentLine = lines.length;
|
||||
|
||||
// Berechne die vertikale Position basierend auf der Zeilennummer
|
||||
// Annahme: Jede Zeile ist etwa 24px hoch
|
||||
const lineHeight = 24;
|
||||
const verticalOffset = (currentLine - 1) * lineHeight;
|
||||
|
||||
// Ermittle die Scroll-Position des TextInputs
|
||||
const scrollTop = input.scrollTop || 0;
|
||||
|
||||
// Berechne die absolute Position des Cursors im Dokument
|
||||
const cursorTop = verticalOffset - scrollTop + 50; // +50 für Padding und Header
|
||||
|
||||
// Setze die Position des Dropdowns unter dem Cursor
|
||||
setDropdownPosition({
|
||||
top: cursorTop + lineHeight, // Unter der aktuellen Zeile
|
||||
left: 20, // Ein wenig eingerückt
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Dropdown-Position: Zeile ${currentLine}, Position: ${cursorTop}px, Scroll: ${scrollTop}px`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Verwende die Position des Mentions im Text
|
||||
if (mentionStartIndex >= 0 && value) {
|
||||
const textBeforeMention = value.substring(0, mentionStartIndex);
|
||||
const lines = textBeforeMention.split('\n');
|
||||
const currentLine = lines.length;
|
||||
const lineHeight = 24;
|
||||
|
||||
// Berechne die Position relativ zum sichtbaren Bereich
|
||||
// @ts-ignore - Wir wissen, dass wir auf Web sind
|
||||
const scrollTop = localInputRef.current._reactInternals?.stateNode?.scrollTop || 0;
|
||||
const verticalOffset = (currentLine - 1) * lineHeight;
|
||||
const cursorTop = verticalOffset - scrollTop + 50; // +50 für Padding und Header
|
||||
|
||||
setDropdownPosition({
|
||||
top: cursorTop + lineHeight,
|
||||
left: 20,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Fallback-Position: Zeile ${currentLine}, Position: ${cursorTop}px, Scroll: ${scrollTop}px`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Berechnung der Dropdown-Position:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Feste Position, wenn keine Berechnung möglich ist
|
||||
setDropdownPosition({ top: 100, left: 20 });
|
||||
};
|
||||
|
||||
// Handle document selection from dropdown
|
||||
const handleSelectDocument = (document: Document) => {
|
||||
// Verwende die kurze ID, wenn verfügbar, sonst die UUID
|
||||
const documentId = document.short_id || document.id;
|
||||
console.log('Dokument ausgewählt:', document.title, 'ID:', documentId);
|
||||
|
||||
// Markdown-Link-Format: [Titel](ID) für beide Formate
|
||||
const linkText = `[${document.title}](${documentId})`;
|
||||
|
||||
// Sicherstellen, dass value definiert ist
|
||||
if (!value) {
|
||||
// Wenn kein Text vorhanden ist, füge einfach den Link ein
|
||||
if (onChangeText) {
|
||||
onChangeText(linkText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Suche nach [[ oder @ im Text
|
||||
const bracketIndex = value.lastIndexOf('[[');
|
||||
const atIndex = value.lastIndexOf('@');
|
||||
|
||||
// Prüfe, welches Format verwendet wurde
|
||||
if (bracketIndex >= 0 && (atIndex < 0 || bracketIndex > atIndex)) {
|
||||
// [[-Format wurde verwendet
|
||||
// Extrahiere den Teil vor [[
|
||||
const beforeBracket = value.substring(0, bracketIndex);
|
||||
|
||||
// Finde das Ende des [[-Blocks (entweder ]] oder das Ende des Textes)
|
||||
let endBracketIndex = value.indexOf(']]', bracketIndex);
|
||||
if (endBracketIndex < 0) {
|
||||
// Wenn kein ]] gefunden wurde, suche nach dem nächsten Leerzeichen oder Zeilenumbruch
|
||||
const nextSpace = value.indexOf(' ', bracketIndex);
|
||||
const nextNewline = value.indexOf('\n', bracketIndex);
|
||||
|
||||
if (nextSpace >= 0 && (nextNewline < 0 || nextSpace < nextNewline)) {
|
||||
endBracketIndex = nextSpace;
|
||||
} else if (nextNewline >= 0) {
|
||||
endBracketIndex = nextNewline;
|
||||
} else {
|
||||
// Wenn weder Leerzeichen noch Zeilenumbruch gefunden wurde, verwende das Ende des Textes
|
||||
endBracketIndex = value.length;
|
||||
}
|
||||
} else {
|
||||
// Wenn ]] gefunden wurde, schließe es mit ein
|
||||
endBracketIndex += 2;
|
||||
}
|
||||
|
||||
// Extrahiere den Teil nach dem [[-Block
|
||||
const afterBracket = value.substring(endBracketIndex);
|
||||
|
||||
// Neuer Text mit eingefügtem Link
|
||||
const newText = beforeBracket + linkText + afterBracket;
|
||||
|
||||
console.log('Neuer Text mit Link (Bracket-Format):', newText);
|
||||
|
||||
// Text aktualisieren
|
||||
if (onChangeText) {
|
||||
onChangeText(newText);
|
||||
}
|
||||
} else if (atIndex >= 0) {
|
||||
// @-Format wurde verwendet
|
||||
// Extrahiere den Teil vor @
|
||||
const beforeAt = value.substring(0, atIndex);
|
||||
|
||||
// Finde das Ende des @-Blocks (nächstes Leerzeichen oder Ende des Textes)
|
||||
let endAtIndex;
|
||||
const nextSpace = value.indexOf(' ', atIndex);
|
||||
const nextNewline = value.indexOf('\n', atIndex);
|
||||
|
||||
if (nextSpace >= 0 && (nextNewline < 0 || nextSpace < nextNewline)) {
|
||||
endAtIndex = nextSpace;
|
||||
} else if (nextNewline >= 0) {
|
||||
endAtIndex = nextNewline;
|
||||
} else {
|
||||
// Wenn weder Leerzeichen noch Zeilenumbruch gefunden wurde, verwende das Ende des Textes
|
||||
endAtIndex = value.length;
|
||||
}
|
||||
|
||||
// Extrahiere den Teil nach dem @-Block
|
||||
const afterAt = value.substring(endAtIndex);
|
||||
|
||||
// Neuer Text mit eingefügtem Link
|
||||
const newText = beforeAt + linkText + afterAt;
|
||||
|
||||
console.log('Neuer Text mit Link (At-Format):', newText);
|
||||
|
||||
// Text aktualisieren
|
||||
if (onChangeText) {
|
||||
onChangeText(newText);
|
||||
}
|
||||
} else {
|
||||
// Weder [[ noch @ gefunden, füge den Link am Ende ein
|
||||
const newText = (value || '') + linkText;
|
||||
if (onChangeText) {
|
||||
onChangeText(newText);
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown ausblenden
|
||||
setShowDropdown(false);
|
||||
setMentionQuery(null);
|
||||
setMentionStartIndex(-1);
|
||||
|
||||
// Notify parent component
|
||||
if (onMentionInserted) {
|
||||
onMentionInserted(document.id, document.title);
|
||||
}
|
||||
|
||||
// Einfacherer Ansatz: Wir verwenden die vorhandene Logik zum Ersetzen des Textes
|
||||
// und stellen nur sicher, dass der Fokus erhalten bleibt
|
||||
if (value) {
|
||||
// Suche nach [[ oder @ im Text
|
||||
const bracketIndex = value.lastIndexOf('[[');
|
||||
const atIndex = value.lastIndexOf('@');
|
||||
|
||||
let newText = value; // Standardmäßig den aktuellen Text beibehalten
|
||||
|
||||
// Prüfe, welches Format verwendet wurde
|
||||
if (bracketIndex >= 0 && (atIndex < 0 || bracketIndex > atIndex)) {
|
||||
// [[-Format wurde verwendet
|
||||
const beforeBracket = value.substring(0, bracketIndex);
|
||||
|
||||
// Finde das Ende des [[-Blocks
|
||||
let endBracketIndex = value.indexOf(']]', bracketIndex);
|
||||
if (endBracketIndex < 0) {
|
||||
// Wenn kein ]] gefunden wurde, suche nach dem nächsten Leerzeichen oder Zeilenumbruch
|
||||
const nextSpace = value.indexOf(' ', bracketIndex);
|
||||
const nextNewline = value.indexOf('\n', bracketIndex);
|
||||
|
||||
if (nextSpace >= 0 && (nextNewline < 0 || nextSpace < nextNewline)) {
|
||||
endBracketIndex = nextSpace;
|
||||
} else if (nextNewline >= 0) {
|
||||
endBracketIndex = nextNewline;
|
||||
} else {
|
||||
// Wenn weder Leerzeichen noch Zeilenumbruch gefunden wurde, verwende das Ende des Textes
|
||||
endBracketIndex = value.length;
|
||||
}
|
||||
} else {
|
||||
// Wenn ]] gefunden wurde, schließe es mit ein
|
||||
endBracketIndex += 2;
|
||||
}
|
||||
|
||||
// Extrahiere den Teil nach dem [[-Block
|
||||
const afterBracket = value.substring(endBracketIndex);
|
||||
|
||||
// Neuer Text mit eingefügtem Link
|
||||
newText = beforeBracket + linkText + afterBracket;
|
||||
} else if (atIndex >= 0) {
|
||||
// @-Format wurde verwendet
|
||||
const beforeAt = value.substring(0, atIndex);
|
||||
|
||||
// Finde das Ende des @-Blocks
|
||||
let endAtIndex;
|
||||
const nextSpace = value.indexOf(' ', atIndex);
|
||||
const nextNewline = value.indexOf('\n', atIndex);
|
||||
|
||||
if (nextSpace >= 0 && (nextNewline < 0 || nextSpace < nextNewline)) {
|
||||
endAtIndex = nextSpace;
|
||||
} else if (nextNewline >= 0) {
|
||||
endAtIndex = nextNewline;
|
||||
} else {
|
||||
// Wenn weder Leerzeichen noch Zeilenumbruch gefunden wurde, verwende das Ende des Textes
|
||||
endAtIndex = value.length;
|
||||
}
|
||||
|
||||
// Extrahiere den Teil nach dem @-Block
|
||||
const afterAt = value.substring(endAtIndex);
|
||||
|
||||
// Neuer Text mit eingefügtem Link
|
||||
newText = beforeAt + linkText + afterAt;
|
||||
} else {
|
||||
// Weder [[ noch @ gefunden, füge den Link am Ende ein
|
||||
newText = (value || '') + linkText;
|
||||
}
|
||||
|
||||
// Text aktualisieren
|
||||
if (onChangeText) {
|
||||
onChangeText(newText);
|
||||
}
|
||||
}
|
||||
|
||||
// Fokus auf das Eingabefeld setzen mit einer Verzögerung
|
||||
// Dies ist wichtig, damit der Fokus nach dem Rendern wiederhergestellt wird
|
||||
const refocusInput = () => {
|
||||
if (localInputRef.current) {
|
||||
localInputRef.current.focus();
|
||||
} else {
|
||||
// Wenn das Ref noch nicht verfügbar ist, versuche es erneut
|
||||
setTimeout(refocusInput, 10);
|
||||
}
|
||||
};
|
||||
|
||||
// Starte den Refokus-Prozess
|
||||
setTimeout(refocusInput, 50);
|
||||
|
||||
console.log('Mention eingefügt:', linkText);
|
||||
};
|
||||
|
||||
// Debug-Ausgaben
|
||||
useEffect(() => {
|
||||
if (showDropdown) {
|
||||
console.log('Dropdown wird angezeigt mit', matchingDocuments.length, 'Dokumenten');
|
||||
}
|
||||
}, [showDropdown, matchingDocuments]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, position: 'relative' }}>
|
||||
<TextInput
|
||||
ref={localInputRef}
|
||||
value={value}
|
||||
onChangeText={handleChangeText}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{/* Debug-Anzeige (nur während der Entwicklung) */}
|
||||
{__DEV__ && debugInfo && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
padding: 5,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: 'white', fontSize: 10 }}>{debugInfo}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Dropdown als Banner oben auf der Seite */}
|
||||
{showDropdown && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<MentionDropdown
|
||||
documents={matchingDocuments}
|
||||
onSelectDocument={handleSelectDocument}
|
||||
visible={true}
|
||||
fullWidth={true}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const MentionTextInput = forwardRef(MentionTextInputBase);
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { getCurrentTokenBalance } from '../../services/tokenTransactionService';
|
||||
import { supabase } from '../../utils/supabase';
|
||||
import { useTheme, themeClasses } from '../../utils/theme/theme';
|
||||
import { eventEmitter, EVENTS } from '../../utils/eventEmitter';
|
||||
|
||||
type TokenDisplayProps = {
|
||||
onPress?: () => void;
|
||||
showLabel?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
estimatedCost?: number; // Geschätzter Tokenverbrauch für die aktuelle Anfrage
|
||||
onInfoPress?: () => void; // Callback für das Info-Icon
|
||||
};
|
||||
|
||||
export const TokenDisplay: React.FC<TokenDisplayProps> = ({
|
||||
onPress,
|
||||
showLabel = true,
|
||||
size = 'medium',
|
||||
estimatedCost,
|
||||
onInfoPress,
|
||||
}) => {
|
||||
const [tokenBalance, setTokenBalance] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { isDark } = useTheme();
|
||||
|
||||
// Größen basierend auf der size-Prop
|
||||
const fontSize = size === 'small' ? 12 : size === 'medium' ? 14 : 16;
|
||||
const iconSize = size === 'small' ? 14 : size === 'medium' ? 16 : 18;
|
||||
const paddingHorizontal = size === 'small' ? 6 : size === 'medium' ? 8 : 10;
|
||||
const paddingVertical = size === 'small' ? 2 : size === 'medium' ? 3 : 4;
|
||||
|
||||
// Funktion zum Laden des Token-Guthabens
|
||||
const loadTokenBalance = useCallback(async () => {
|
||||
console.log('TokenDisplay: Lade Token-Guthaben...');
|
||||
try {
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const userId = sessionData?.session?.user?.id;
|
||||
|
||||
if (userId) {
|
||||
const balance = await getCurrentTokenBalance(userId);
|
||||
console.log('TokenDisplay: Neues Token-Guthaben geladen:', balance);
|
||||
setTokenBalance(balance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Token-Guthabens:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Event-Handler für Token-Balance-Updates
|
||||
const handleTokenBalanceUpdated = useCallback(() => {
|
||||
console.log('TokenDisplay: TOKEN_BALANCE_UPDATED Event empfangen, lade Guthaben neu');
|
||||
loadTokenBalance();
|
||||
}, [loadTokenBalance]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial laden
|
||||
loadTokenBalance();
|
||||
|
||||
// Event-Listener registrieren
|
||||
eventEmitter.on(EVENTS.TOKEN_BALANCE_UPDATED, handleTokenBalanceUpdated);
|
||||
|
||||
// Aktualisiere das Guthaben alle 5 Minuten
|
||||
const intervalId = setInterval(loadTokenBalance, 5 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
// Event-Listener entfernen
|
||||
eventEmitter.off(EVENTS.TOKEN_BALANCE_UPDATED, handleTokenBalanceUpdated);
|
||||
};
|
||||
}, [loadTokenBalance, handleTokenBalanceUpdated]);
|
||||
|
||||
// Formatiere das Token-Guthaben für die Anzeige
|
||||
const formattedBalance = tokenBalance !== null ? tokenBalance.toLocaleString() : '---';
|
||||
|
||||
// Berechne das verbleibende Guthaben nach Abzug der geschätzten Kosten
|
||||
const remainingBalance =
|
||||
tokenBalance !== null && estimatedCost !== undefined
|
||||
? Math.max(0, tokenBalance - estimatedCost)
|
||||
: null;
|
||||
|
||||
// Formatiere das verbleibende Guthaben für die Anzeige
|
||||
const formattedRemainingBalance =
|
||||
remainingBalance !== null ? remainingBalance.toLocaleString() : null;
|
||||
|
||||
const containerStyle = {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
|
||||
paddingHorizontal,
|
||||
paddingVertical,
|
||||
borderRadius: 16,
|
||||
marginHorizontal: 4,
|
||||
};
|
||||
|
||||
const textStyle = {
|
||||
color: isDark ? '#ffffff' : '#000000',
|
||||
fontSize,
|
||||
fontWeight: '500' as const,
|
||||
marginLeft: showLabel ? 4 : 0,
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)',
|
||||
fontSize: fontSize - 2,
|
||||
marginRight: 4,
|
||||
};
|
||||
|
||||
const infoIconStyle = {
|
||||
marginLeft: 4,
|
||||
fontSize: iconSize,
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)',
|
||||
};
|
||||
|
||||
const estimatedCostStyle = {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)',
|
||||
fontSize: fontSize - 2,
|
||||
marginLeft: 4,
|
||||
};
|
||||
|
||||
// Wenn onInfoPress vorhanden ist, machen wir den gesamten Bereich klickbar
|
||||
const tokenDisplayContent = (
|
||||
<>
|
||||
{showLabel && <Text style={labelStyle}>Tokens:</Text>}
|
||||
|
||||
{/* Zeige das aktuelle Guthaben an */}
|
||||
<Text style={textStyle}>{formattedBalance}</Text>
|
||||
|
||||
{/* Zeige den geschätzten Tokenverbrauch an, wenn vorhanden */}
|
||||
{estimatedCost !== undefined && formattedRemainingBalance !== null && (
|
||||
<Text style={estimatedCostStyle}>{` → ${formattedRemainingBalance}`}</Text>
|
||||
)}
|
||||
|
||||
{/* Info-Icon, das die detaillierte Token-Schätzung öffnet */}
|
||||
{onInfoPress && <Text style={infoIconStyle}>ℹ️</Text>}
|
||||
</>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={isDark ? '#ffffff' : '#000000'} />
|
||||
) : onInfoPress ? (
|
||||
<TouchableOpacity
|
||||
onPress={onInfoPress}
|
||||
style={{ flexDirection: 'row', alignItems: 'center' }}
|
||||
>
|
||||
{tokenDisplayContent}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
tokenDisplayContent
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export default TokenDisplay;
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { estimateCostForPrompt } from '../../services/tokenCountingService';
|
||||
import { getCurrentTokenBalance } from '../../services/tokenTransactionService';
|
||||
import { supabase } from '../../utils/supabase';
|
||||
import { useTheme } from '../../utils/theme/theme';
|
||||
|
||||
type TokenEstimatorProps = {
|
||||
estimate: any; // Die bereits berechnete Token-Schätzung
|
||||
estimatedCompletionLength?: number;
|
||||
onClose?: () => void; // Optional: Callback zum Schließen der Vorschau
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const TokenEstimator: React.FC<TokenEstimatorProps> = ({
|
||||
estimate,
|
||||
estimatedCompletionLength = 500,
|
||||
onClose,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
// Wir verwenden jetzt die übergebene Schätzung direkt
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { isDark } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const loadEstimate = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Hole den aktuellen Benutzer
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const userId = sessionData?.session?.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Nicht angemeldet');
|
||||
}
|
||||
|
||||
// WICHTIG: Wir rufen estimateCostForPrompt NICHT mehr direkt auf,
|
||||
// da die Schätzung bereits von der aufrufenden Komponente berechnet wurde
|
||||
// und in der estimate-Prop enthalten ist.
|
||||
|
||||
// Hole das aktuelle Token-Guthaben
|
||||
const tokenBalance = await getCurrentTokenBalance(userId);
|
||||
setBalance(tokenBalance);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Token-Schätzung:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Nur das Token-Guthaben laden, wenn wir bereits eine Schätzung haben
|
||||
loadEstimate();
|
||||
}, []);
|
||||
|
||||
// Bestimme, ob genügend Tokens vorhanden sind
|
||||
const hasEnoughTokens = balance !== null && estimate && balance >= estimate.appTokens;
|
||||
|
||||
// Container-Stil
|
||||
const containerStyle = {
|
||||
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(240, 240, 240, 0.9)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginVertical: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
};
|
||||
|
||||
// Text-Stil
|
||||
const textStyle = {
|
||||
color: isDark ? '#ffffff' : '#000000',
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
// Hervorgehobener Text-Stil
|
||||
const highlightTextStyle = {
|
||||
...textStyle,
|
||||
fontWeight: '600' as const,
|
||||
};
|
||||
|
||||
// Button-Container-Stil
|
||||
const buttonContainerStyle = {
|
||||
flexDirection: 'row' as const,
|
||||
justifyContent: 'flex-end' as const,
|
||||
marginTop: 12,
|
||||
};
|
||||
|
||||
// Button-Stil
|
||||
const buttonStyle = {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 4,
|
||||
marginLeft: 8,
|
||||
};
|
||||
|
||||
// Abbrechen-Button-Stil
|
||||
const cancelButtonStyle = {
|
||||
...buttonStyle,
|
||||
backgroundColor: isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
};
|
||||
|
||||
// Senden-Button-Stil
|
||||
const submitButtonStyle = {
|
||||
...buttonStyle,
|
||||
backgroundColor: hasEnoughTokens
|
||||
? isDark
|
||||
? '#3b82f6'
|
||||
: '#2563eb'
|
||||
: isDark
|
||||
? '#6b7280'
|
||||
: '#9ca3af',
|
||||
};
|
||||
|
||||
// Button-Text-Stil
|
||||
const buttonTextStyle = {
|
||||
color: isDark ? '#ffffff' : '#000000',
|
||||
fontSize: 14,
|
||||
fontWeight: '500' as const,
|
||||
};
|
||||
|
||||
// Senden-Button-Text-Stil
|
||||
const submitButtonTextStyle = {
|
||||
...buttonTextStyle,
|
||||
color: '#ffffff',
|
||||
};
|
||||
|
||||
// Warnungs-Stil
|
||||
const warningStyle = {
|
||||
...textStyle,
|
||||
color: isDark ? '#f87171' : '#dc2626',
|
||||
fontWeight: '600' as const,
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<ActivityIndicator size="small" color={isDark ? '#ffffff' : '#000000'} />
|
||||
<Text style={textStyle}>Schätze Token-Kosten...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text style={textStyle}>Geschätzte Token-Kosten:</Text>
|
||||
|
||||
{estimate && (
|
||||
<>
|
||||
<Text style={textStyle}>
|
||||
<Text style={highlightTextStyle}>Input:</Text> {estimate.inputTokens.toLocaleString()}{' '}
|
||||
Tokens
|
||||
</Text>
|
||||
{estimate.basePromptTokens !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
<Text style={highlightTextStyle}>Basis-Prompt:</Text>{' '}
|
||||
{estimate.basePromptTokens.toLocaleString()} Tokens
|
||||
</Text>
|
||||
)}
|
||||
{estimate.documentTokens !== undefined && estimate.documentTokens > 0 && (
|
||||
<Text style={textStyle}>
|
||||
<Text style={highlightTextStyle}>Referenzierte Dokumente:</Text>{' '}
|
||||
{estimate.documentTokens.toLocaleString()} Tokens
|
||||
{(estimate as any).referencedDocCount > 0 &&
|
||||
` (${(estimate as any).referencedDocCount} Dokumente)`}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={textStyle}>
|
||||
<Text style={highlightTextStyle}>Output (geschätzt):</Text>{' '}
|
||||
{estimatedCompletionLength.toLocaleString()} Tokens
|
||||
</Text>
|
||||
<Text style={textStyle}>
|
||||
<Text style={highlightTextStyle}>Gesamt:</Text>{' '}
|
||||
{(estimate.inputTokens + estimatedCompletionLength).toLocaleString()} Tokens
|
||||
</Text>
|
||||
<Text style={highlightTextStyle}>
|
||||
Kosten: {estimate.appTokens.toLocaleString()} App-Tokens
|
||||
</Text>
|
||||
|
||||
{balance !== null && (
|
||||
<Text style={textStyle}>
|
||||
<Text style={highlightTextStyle}>Aktuelles Guthaben:</Text> {balance.toLocaleString()}{' '}
|
||||
Tokens
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!hasEnoughTokens && (
|
||||
<Text style={warningStyle}>
|
||||
Nicht genügend Tokens! Sie benötigen{' '}
|
||||
{Math.max(0, estimate.appTokens - (balance || 0)).toLocaleString()} weitere Tokens.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<View style={buttonContainerStyle}>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: isDark ? '#4b5563' : '#d1d5db',
|
||||
}}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Text style={buttonTextStyle}>Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenEstimator;
|
||||
404
apps/context/apps/mobile/components/monetization/TokenStore.tsx
Normal file
404
apps/context/apps/mobile/components/monetization/TokenStore.tsx
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { PurchasesPackage, PACKAGE_TYPE } from 'react-native-purchases';
|
||||
import {
|
||||
getOfferings,
|
||||
purchasePackage,
|
||||
getCurrentTokenBalance,
|
||||
TOKEN_AMOUNTS,
|
||||
ENTITLEMENTS,
|
||||
} from '../../services/revenueCatService';
|
||||
import { supabase } from '../../utils/supabase';
|
||||
import { themeClasses, useColorModeValue } from '../../utils/theme/theme';
|
||||
|
||||
type TokenStoreProps = {
|
||||
onClose?: () => void;
|
||||
onPurchaseComplete?: () => void;
|
||||
};
|
||||
|
||||
export const TokenStore: React.FC<TokenStoreProps> = ({ onClose, onPurchaseComplete }) => {
|
||||
const [user, setUser] = useState<{ id: string } | null>(null);
|
||||
const [packages, setPackages] = useState<PurchasesPackage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
const [tokenBalance, setTokenBalance] = useState<number | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'subscription' | 'onetime'>('subscription');
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const cardBgColor = useColorModeValue('gray.50', 'gray.700');
|
||||
const accentColor = useColorModeValue('blue.500', 'blue.300');
|
||||
|
||||
useEffect(() => {
|
||||
// Aktuellen Benutzer laden
|
||||
const loadUser = async () => {
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
if (sessionData?.session?.user) {
|
||||
setUser({ id: sessionData.session.user.id });
|
||||
}
|
||||
};
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Angebote laden
|
||||
const offerings = await getOfferings();
|
||||
if (offerings) {
|
||||
setPackages(offerings);
|
||||
}
|
||||
|
||||
// Token-Guthaben laden
|
||||
const balance = await getCurrentTokenBalance();
|
||||
setTokenBalance(balance);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Store-Daten:', error);
|
||||
Alert.alert('Fehler', 'Beim Laden der Angebote ist ein Fehler aufgetreten.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (user) {
|
||||
loadData();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handlePurchase = async (pkg: PurchasesPackage) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
setPurchasing(true);
|
||||
|
||||
// Kaufe das Paket
|
||||
const success = await purchasePackage(pkg);
|
||||
|
||||
if (success) {
|
||||
// Aktualisiere das Token-Guthaben
|
||||
const newBalance = await getCurrentTokenBalance();
|
||||
setTokenBalance(newBalance);
|
||||
|
||||
// Benachrichtige den Benutzer
|
||||
Alert.alert('Kauf erfolgreich', 'Dein Credit-Guthaben wurde aktualisiert.', [
|
||||
{ text: 'OK', onPress: () => onPurchaseComplete?.() },
|
||||
]);
|
||||
} else {
|
||||
// Fehlerbehandlung
|
||||
Alert.alert('Kauf fehlgeschlagen', 'Ein unbekannter Fehler ist aufgetreten.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Kauf:', error);
|
||||
Alert.alert('Kauf fehlgeschlagen', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setPurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Diese Funktion wird nicht mehr verwendet, da wir jetzt getCreditsForPackage verwenden
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: bgColor }]}>
|
||||
<ActivityIndicator size="large" color={accentColor} />
|
||||
<Text style={[styles.loadingText, { color: textColor }]}>Angebote werden geladen...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Filtere Pakete nach Typ (Abonnement oder Einmalkauf)
|
||||
const subscriptionPackages = packages.filter(
|
||||
(pkg) => pkg.packageType === PACKAGE_TYPE.MONTHLY || pkg.packageType === PACKAGE_TYPE.ANNUAL
|
||||
);
|
||||
|
||||
const onetimePackages = packages.filter(
|
||||
(pkg) => pkg.packageType === PACKAGE_TYPE.CUSTOM || pkg.packageType === PACKAGE_TYPE.LIFETIME
|
||||
);
|
||||
|
||||
// Formatiere Credits in Millionen
|
||||
const formatCredits = (credits: number) => {
|
||||
const millions = credits / 1000000;
|
||||
return millions.toFixed(1).replace(/\.0$/, '') + ' Mio';
|
||||
};
|
||||
|
||||
// Bestimme den Pakettyp basierend auf der Produkt-ID
|
||||
const getPackageType = (pkg: PurchasesPackage) => {
|
||||
const productId = pkg.product.identifier.toLowerCase();
|
||||
if (productId.includes('mini') || productId.includes('plus') || productId.includes('pro')) {
|
||||
return 'subscription';
|
||||
}
|
||||
return 'onetime';
|
||||
};
|
||||
|
||||
// Bestimme die Anzahl der Credits basierend auf der Produkt-ID
|
||||
const getCreditsForPackage = (pkg: PurchasesPackage): number => {
|
||||
const productId = pkg.product.identifier;
|
||||
|
||||
// Abonnements
|
||||
if (productId.includes('Mini_5E')) return TOKEN_AMOUNTS[ENTITLEMENTS.MINI_SUB];
|
||||
if (productId.includes('Plus_11E')) return TOKEN_AMOUNTS[ENTITLEMENTS.PLUS_SUB];
|
||||
if (productId.includes('Pro_18E')) return TOKEN_AMOUNTS[ENTITLEMENTS.PRO_SUB];
|
||||
|
||||
// Einmalkäufe
|
||||
if (productId.includes('Small_5E')) return TOKEN_AMOUNTS[ENTITLEMENTS.SMALL_TOKENS];
|
||||
if (productId.includes('Medium_10E')) return TOKEN_AMOUNTS[ENTITLEMENTS.MEDIUM_TOKENS];
|
||||
if (productId.includes('Large_20E')) return TOKEN_AMOUNTS[ENTITLEMENTS.LARGE_TOKENS];
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: bgColor }]}>
|
||||
<Text style={[styles.title, { color: textColor }]}>Credits kaufen</Text>
|
||||
|
||||
{tokenBalance !== null && (
|
||||
<View style={styles.balanceContainer}>
|
||||
<Text style={[styles.balanceText, { color: textColor }]}>
|
||||
Aktuelles Guthaben:{' '}
|
||||
<Text style={styles.balanceAmount}>{tokenBalance.toLocaleString()} Credits</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Tabs für Abonnements und Einmalkäufe */}
|
||||
<View style={styles.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tabButton, activeTab === 'subscription' && styles.activeTabButton]}
|
||||
onPress={() => setActiveTab('subscription')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabButtonText,
|
||||
activeTab === 'subscription' && styles.activeTabButtonText,
|
||||
]}
|
||||
>
|
||||
Abonnements
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tabButton, activeTab === 'onetime' && styles.activeTabButton]}
|
||||
onPress={() => setActiveTab('onetime')}
|
||||
>
|
||||
<Text
|
||||
style={[styles.tabButtonText, activeTab === 'onetime' && styles.activeTabButtonText]}
|
||||
>
|
||||
Einmalkäufe
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.packagesContainer}>
|
||||
{packages.length === 0 ? (
|
||||
<Text style={[styles.noPackagesText, { color: textColor }]}>
|
||||
Keine Angebote verfügbar. Bitte versuche es später erneut.
|
||||
</Text>
|
||||
) : activeTab === 'subscription' ? (
|
||||
// Abonnements anzeigen
|
||||
subscriptionPackages.length > 0 ? (
|
||||
subscriptionPackages.map((pkg, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.packageCard, { backgroundColor: cardBgColor }]}
|
||||
onPress={() => handlePurchase(pkg)}
|
||||
disabled={purchasing}
|
||||
>
|
||||
<View style={styles.packageInfo}>
|
||||
<Text style={[styles.packageTitle, { color: textColor }]}>
|
||||
{pkg.product.title}
|
||||
</Text>
|
||||
<Text style={[styles.packageDescription, { color: textColor }]}>
|
||||
{formatCredits(getCreditsForPackage(pkg))} Credits monatlich
|
||||
</Text>
|
||||
<Text style={[styles.packagePrice, { color: accentColor }]}>
|
||||
{pkg.product.priceString} / Monat
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.buyButtonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.buyButton, { backgroundColor: accentColor }]}
|
||||
onPress={() => handlePurchase(pkg)}
|
||||
disabled={purchasing}
|
||||
>
|
||||
{purchasing ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Text style={styles.buyButtonText}>Abonnieren</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<Text style={[styles.noPackagesText, { color: textColor }]}>
|
||||
Keine Abonnements verfügbar. Bitte versuche es später erneut.
|
||||
</Text>
|
||||
)
|
||||
) : // Einmalkäufe anzeigen
|
||||
onetimePackages.length > 0 ? (
|
||||
onetimePackages.map((pkg, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.packageCard, { backgroundColor: cardBgColor }]}
|
||||
onPress={() => handlePurchase(pkg)}
|
||||
disabled={purchasing}
|
||||
>
|
||||
<View style={styles.packageInfo}>
|
||||
<Text style={[styles.packageTitle, { color: textColor }]}>{pkg.product.title}</Text>
|
||||
<Text style={[styles.packageDescription, { color: textColor }]}>
|
||||
{formatCredits(getCreditsForPackage(pkg))} Credits
|
||||
</Text>
|
||||
<Text style={[styles.packagePrice, { color: accentColor }]}>
|
||||
{pkg.product.priceString}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.buyButtonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.buyButton, { backgroundColor: accentColor }]}
|
||||
onPress={() => handlePurchase(pkg)}
|
||||
disabled={purchasing}
|
||||
>
|
||||
{purchasing ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Text style={styles.buyButtonText}>Kaufen</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<Text style={[styles.noPackagesText, { color: textColor }]}>
|
||||
Keine Einmalkäufe verfügbar. Bitte versuche es später erneut.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Text style={[styles.closeButtonText, { color: textColor }]}>Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
balanceContainer: {
|
||||
marginBottom: 24,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
balanceText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
balanceAmount: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: '#ddd',
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
activeTabButton: {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
tabButtonText: {
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
},
|
||||
activeTabButtonText: {
|
||||
color: 'white',
|
||||
},
|
||||
packagesContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
noPackagesText: {
|
||||
textAlign: 'center',
|
||||
marginTop: 24,
|
||||
},
|
||||
packageCard: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
packageInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
packageTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
},
|
||||
packageDescription: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
packagePrice: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
buyButtonContainer: {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buyButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
buyButtonText: {
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
closeButton: {
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default TokenStore;
|
||||
398
apps/context/apps/mobile/components/navigation/Breadcrumbs.tsx
Normal file
398
apps/context/apps/mobile/components/navigation/Breadcrumbs.tsx
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
ScrollView,
|
||||
findNodeHandle,
|
||||
UIManager,
|
||||
Pressable,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { useTheme, twMerge, useThemeClasses } from '~/utils/theme/theme';
|
||||
import { Skeleton } from '~/components/ui/Skeleton';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
id?: string;
|
||||
dropdownItems?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
}>;
|
||||
customComponent?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items: BreadcrumbItem[];
|
||||
className?: string;
|
||||
showSettingsIcon?: boolean;
|
||||
onSettingsPress?: () => void;
|
||||
loading?: boolean;
|
||||
rightComponent?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Styles für die Breadcrumbs-Komponente
|
||||
const styles = StyleSheet.create({
|
||||
breadcrumbItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
...(Platform.OS === 'web' ? { transition: 'all 0.2s ease' } : {}),
|
||||
},
|
||||
breadcrumbItemHovered: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
textHovered: {
|
||||
// Kein fontWeight mehr, um zu verhindern, dass das Layout springt
|
||||
},
|
||||
});
|
||||
|
||||
export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({
|
||||
items,
|
||||
className = '',
|
||||
showSettingsIcon = false,
|
||||
onSettingsPress,
|
||||
loading = false,
|
||||
rightComponent,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { mode, themeName } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
const themeClasses = useThemeClasses();
|
||||
const { width } = useWindowDimensions();
|
||||
const isDesktop = width > 1024;
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ x: 0 });
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const itemRefs = useRef<Array<any>>([]);
|
||||
const searchInputRef = useRef<TextInput>(null);
|
||||
|
||||
// Berechne die Position des Dropdowns basierend auf dem angeklickten Element
|
||||
const measureItem = (index: number) => {
|
||||
if (itemRefs.current[index]) {
|
||||
const handle = findNodeHandle(itemRefs.current[index]);
|
||||
if (handle) {
|
||||
UIManager.measure(handle, (x, y, width, height, pageX, pageY) => {
|
||||
setDropdownPosition({ x: pageX });
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemPress = (index: number, href?: string) => {
|
||||
const item = items[index];
|
||||
if (item?.dropdownItems && item.dropdownItems.length > 0) {
|
||||
measureItem(index);
|
||||
setActiveDropdown(activeDropdown === index ? null : index);
|
||||
} else if (href) {
|
||||
router.push(href as any);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
setActiveDropdown(null);
|
||||
};
|
||||
|
||||
const toggleSearch = () => {
|
||||
setShowSearch(!showSearch);
|
||||
// Focus the search input when it becomes visible
|
||||
if (!showSearch) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 100);
|
||||
} else {
|
||||
setSearchText('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
// Implement your search functionality here
|
||||
console.log('Searching for:', searchText);
|
||||
// Example: router.push(`/search?q=${encodeURIComponent(searchText)}`);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: any) => {
|
||||
if (e.nativeEvent.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// Skeleton Loader für Breadcrumbs
|
||||
if (loading) {
|
||||
return (
|
||||
<View
|
||||
className={`flex-row items-center justify-between h-10 ${className}`}
|
||||
style={{ backgroundColor: 'transparent', width: '100%' }}
|
||||
>
|
||||
{/* Left side container for search and breadcrumb items */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{/* Skeleton für Search Icon */}
|
||||
<View style={{ marginRight: 8, padding: 4 }}>
|
||||
<Skeleton width={20} height={20} borderRadius={10} />
|
||||
</View>
|
||||
|
||||
{/* Skeleton für Breadcrumb Items */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Skeleton width={80} height={16} />
|
||||
<Text style={{ marginHorizontal: 8, color: isDark ? '#4b5563' : '#d1d5db' }}>/</Text>
|
||||
<Skeleton width={120} height={16} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Skeleton für Settings Icon (falls vorhanden) */}
|
||||
{showSettingsIcon && (
|
||||
<View style={{ marginLeft: 'auto' }}>
|
||||
<Skeleton width={24} height={24} borderRadius={12} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`flex-row items-center justify-between h-10 ${className}`}
|
||||
style={{ backgroundColor: 'transparent', width: '100%' }}
|
||||
>
|
||||
{/* Left side container for search and breadcrumb items */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<TouchableOpacity
|
||||
onPress={toggleSearch}
|
||||
className="mr-2 flex-row items-center justify-center"
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={showSearch ? 'search' : 'search'}
|
||||
size={20}
|
||||
color={isDark ? '#d1d5db' : '#4b5563'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{!showSearch ? (
|
||||
<>
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`breadcrumb-${index}`}>
|
||||
{item.customComponent ? (
|
||||
<>
|
||||
<Text
|
||||
className={twMerge(
|
||||
isDesktop ? 'text-base' : 'text-sm',
|
||||
'font-medium text-gray-800 dark:text-gray-200 mr-2'
|
||||
)}
|
||||
>
|
||||
{item.label}:
|
||||
</Text>
|
||||
<View style={{ marginLeft: 4 }}>{item.customComponent}</View>
|
||||
</>
|
||||
) : item.href !== undefined ||
|
||||
(item.dropdownItems && item.dropdownItems.length > 0) ? (
|
||||
<Pressable
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
onPress={() => handleItemPress(index, item.href)}
|
||||
className="flex-row items-center"
|
||||
style={({ pressed }) => [
|
||||
styles.breadcrumbItem,
|
||||
pressed && !isLast && styles.breadcrumbItemHovered,
|
||||
]}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<>
|
||||
<Text
|
||||
className={twMerge(
|
||||
isDesktop ? 'text-base' : 'text-sm',
|
||||
isLast
|
||||
? 'font-medium text-gray-800 dark:text-gray-200'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
)}
|
||||
style={[
|
||||
pressed && !isLast && styles.textHovered,
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.dropdownItems && item.dropdownItems.length > 0 && (
|
||||
<Ionicons
|
||||
name={activeDropdown === index ? 'chevron-up' : 'chevron-down'}
|
||||
size={14}
|
||||
color={isDark ? '#d1d5db' : '#4b5563'}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
) : (
|
||||
<View className="flex-row items-center" style={styles.breadcrumbItem}>
|
||||
<Text
|
||||
className={twMerge(
|
||||
isDesktop ? 'text-base' : 'text-sm',
|
||||
isLast
|
||||
? 'font-medium text-gray-800 dark:text-gray-200'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isLast && (
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={14}
|
||||
color={isDark ? '#d1d5db' : '#4b5563'}
|
||||
style={{ marginHorizontal: 4 }}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<TextInput
|
||||
ref={searchInputRef}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Suchen..."
|
||||
className={twMerge(
|
||||
'flex-1 px-2',
|
||||
isDark
|
||||
? 'text-white bg-gray-800 border-gray-700'
|
||||
: 'text-gray-900 bg-white border-gray-300'
|
||||
)}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
height: 28,
|
||||
}}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={handleSearch}
|
||||
className="ml-2 flex-row items-center justify-center"
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<Ionicons name="arrow-forward" size={20} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={toggleSearch}
|
||||
className="ml-2 flex-row items-center justify-center"
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<Ionicons name="close" size={20} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{/* Dropdown Menu als Modal */}
|
||||
{activeDropdown !== null && items[activeDropdown]?.dropdownItems && (
|
||||
<Modal
|
||||
transparent={true}
|
||||
visible={activeDropdown !== null}
|
||||
onRequestClose={closeDropdown}
|
||||
animationType="fade"
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent', // Keine Abdunkelung
|
||||
}}
|
||||
activeOpacity={1}
|
||||
onPress={closeDropdown}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 40, // Direkt unter den Breadcrumbs
|
||||
left: dropdownPosition.x, // Bündig unter dem angeklickten Element
|
||||
minWidth: 200,
|
||||
maxWidth: 300,
|
||||
maxHeight: 300,
|
||||
borderRadius: 0, // Keine abgerundeten Ecken
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 3,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity activeOpacity={1} onPress={(e) => e.stopPropagation()}>
|
||||
<View
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
borderRadius: 0,
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
}}
|
||||
>
|
||||
<ScrollView style={{ maxHeight: 256 }}>
|
||||
{items[activeDropdown].dropdownItems?.map((dropdownItem) => (
|
||||
<Pressable
|
||||
key={dropdownItem.id}
|
||||
style={({ pressed }) => [
|
||||
{
|
||||
padding: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: isDark ? '#374151' : '#e5e7eb',
|
||||
backgroundColor: pressed
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#f3f4f6'
|
||||
: isDark
|
||||
? '#1f2937'
|
||||
: '#ffffff',
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
closeDropdown();
|
||||
router.push(dropdownItem.href as any);
|
||||
}}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<Text
|
||||
style={[
|
||||
{ color: isDark ? '#f3f4f6' : '#1f2937' },
|
||||
pressed && styles.textHovered,
|
||||
]}
|
||||
>
|
||||
{dropdownItem.label}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right component or settings icon */}
|
||||
{rightComponent ? (
|
||||
<View style={{ marginLeft: 'auto' }}>{rightComponent}</View>
|
||||
) : (
|
||||
showSettingsIcon && (
|
||||
<TouchableOpacity onPress={onSettingsPress} style={{ marginLeft: 'auto', padding: 4 }}>
|
||||
<Ionicons name="settings-outline" size={24} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
186
apps/context/apps/mobile/components/settings/LanguagePicker.tsx
Normal file
186
apps/context/apps/mobile/components/settings/LanguagePicker.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, Modal, StyleSheet, ScrollView } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useI18n } from '~/context/I18nContext';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface LanguagePickerProps {
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export const LanguagePicker: React.FC<LanguagePickerProps> = ({ style }) => {
|
||||
const { isDark } = useTheme();
|
||||
const { language, supportedLanguages, setLanguage } = useI18n();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const currentLanguage = supportedLanguages.find((lang) => lang.code === language);
|
||||
|
||||
const handleLanguageSelect = async (languageCode: string) => {
|
||||
await setLanguage(languageCode as any);
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.picker,
|
||||
{ backgroundColor: isDark ? '#1f2937' : '#ffffff' },
|
||||
{ borderColor: isDark ? '#374151' : '#e5e7eb' },
|
||||
]}
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<View style={styles.pickerContent}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="language-outline" size={20} color={isDark ? '#9ca3af' : '#6b7280'} />
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.label, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
Language / Sprache
|
||||
</Text>
|
||||
<Text style={[styles.value, { color: isDark ? '#d1d5db' : '#4b5563' }]}>
|
||||
{currentLanguage?.nativeName || 'English'}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={isDark ? '#9ca3af' : '#6b7280'} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: isDark ? '#1f2937' : '#ffffff' }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
Select Language
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setIsModalVisible(false)} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={24} color={isDark ? '#9ca3af' : '#6b7280'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.languageList}>
|
||||
{supportedLanguages.map((lang) => (
|
||||
<TouchableOpacity
|
||||
key={lang.code}
|
||||
style={[
|
||||
styles.languageItem,
|
||||
{ borderBottomColor: isDark ? '#374151' : '#e5e7eb' },
|
||||
language === lang.code && styles.selectedLanguageItem,
|
||||
]}
|
||||
onPress={() => handleLanguageSelect(lang.code)}
|
||||
>
|
||||
<View style={styles.languageItemContent}>
|
||||
<Text style={[styles.languageName, { color: isDark ? '#f9fafb' : '#1f2937' }]}>
|
||||
{lang.nativeName}
|
||||
</Text>
|
||||
<Text style={[styles.languageCode, { color: isDark ? '#9ca3af' : '#6b7280' }]}>
|
||||
{lang.name}
|
||||
</Text>
|
||||
</View>
|
||||
{language === lang.code && (
|
||||
<Ionicons name="checkmark" size={20} color={isDark ? '#818cf8' : '#4f46e5'} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
picker: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
pickerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: 12,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
width: '80%',
|
||||
maxWidth: 400,
|
||||
maxHeight: '70%',
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
languageList: {
|
||||
maxHeight: 300,
|
||||
},
|
||||
languageItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
selectedLanguageItem: {
|
||||
backgroundColor: 'rgba(129, 140, 248, 0.1)',
|
||||
},
|
||||
languageItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
languageName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
},
|
||||
languageCode: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { FilterPill } from '~/components/ui/FilterPill';
|
||||
|
||||
interface AllSpacesFilterPillProps {
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const AllSpacesFilterPill: React.FC<AllSpacesFilterPillProps> = ({
|
||||
isSelected,
|
||||
onPress,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const navigateToAllSpaces = () => {
|
||||
router.push('/spaces');
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
label="Alle"
|
||||
isSelected={isSelected}
|
||||
variant="space"
|
||||
onPress={onPress}
|
||||
actionButton={{
|
||||
icon: 'chevron-forward',
|
||||
onPress: navigateToAllSpaces,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
146
apps/context/apps/mobile/components/spaces/DeleteSpaceButton.tsx
Normal file
146
apps/context/apps/mobile/components/spaces/DeleteSpaceButton.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Modal, View, StyleSheet, Alert } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { ThemedButton } from '~/components/ui/ThemedButton';
|
||||
import { deleteSpace } from '~/services/supabaseService';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface DeleteSpaceButtonProps {
|
||||
spaceId: string;
|
||||
spaceName: string;
|
||||
onDelete: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
export const DeleteSpaceButton: React.FC<DeleteSpaceButtonProps> = ({
|
||||
spaceId,
|
||||
spaceName,
|
||||
onDelete,
|
||||
variant = 'secondary',
|
||||
iconOnly = true,
|
||||
}) => {
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const { mode, colors } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const { success, error } = await deleteSpace(spaceId);
|
||||
if (success) {
|
||||
setShowConfirmation(false);
|
||||
onDelete();
|
||||
} else {
|
||||
Alert.alert('Fehler', `Space konnte nicht gelöscht werden: ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Space:', error);
|
||||
Alert.alert('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemedButton
|
||||
title="Löschen"
|
||||
iconName="trash-outline"
|
||||
variant={variant}
|
||||
iconOnly={iconOnly}
|
||||
tooltip="Space löschen"
|
||||
onPress={() => setShowConfirmation(true)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={showConfirmation}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowConfirmation(false)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.modalOverlay,
|
||||
{ backgroundColor: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.5)' },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.modalContent, { backgroundColor: isDark ? '#1f2937' : '#ffffff' }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Ionicons
|
||||
name="warning-outline"
|
||||
size={24}
|
||||
color={isDark ? '#fbbf24' : '#d97706'}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={[styles.modalTitle, { color: isDark ? '#f9fafb' : '#111827' }]}>
|
||||
Space löschen
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.modalMessage, { color: isDark ? '#d1d5db' : '#4b5563' }]}>
|
||||
Möchtest du den Space "{spaceName}" wirklich löschen? Diese Aktion kann nicht
|
||||
rückgängig gemacht werden. Alle Dokumente in diesem Space werden ebenfalls gelöscht.
|
||||
</Text>
|
||||
|
||||
<View style={styles.modalActions}>
|
||||
<ThemedButton
|
||||
title="Abbrechen"
|
||||
onPress={() => setShowConfirmation(false)}
|
||||
variant="secondary"
|
||||
style={{ marginRight: 8 }}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
<ThemedButton
|
||||
title={isDeleting ? 'Wird gelöscht...' : 'Löschen'}
|
||||
onPress={handleDelete}
|
||||
variant="danger"
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
modalMessage: {
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
lineHeight: 24,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Platform,
|
||||
KeyboardAvoidingView,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { createSpace } from '~/services/supabaseService';
|
||||
|
||||
interface InlineSpaceCreatorProps {
|
||||
onCancel: () => void;
|
||||
onCreated: (spaceId: string) => void;
|
||||
}
|
||||
|
||||
export const InlineSpaceCreator: React.FC<InlineSpaceCreatorProps> = ({ onCancel, onCreated }) => {
|
||||
const { isDark } = useTheme();
|
||||
const [name, setName] = useState('');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Fokussiere das Input-Feld beim Rendern
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// Funktion zum Erstellen des Space
|
||||
const handleCreateSpace = async () => {
|
||||
if (!name.trim()) return;
|
||||
if (creating) return; // Verhindert doppelte Erstellung
|
||||
|
||||
try {
|
||||
setCreating(true);
|
||||
|
||||
const { data, error: createError } = await createSpace(name.trim());
|
||||
|
||||
if (createError) {
|
||||
console.error(`Fehler beim Erstellen des Space: ${createError.message || createError}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// Callback für erfolgreiche Erstellung
|
||||
onCreated(data.id);
|
||||
// Formular zurücksetzen
|
||||
setName('');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Unerwarteter Fehler: ${err.message}`);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Behandle Tastatureingaben (Enter und Escape)
|
||||
const handleKeyPress = (e: any) => {
|
||||
if (e.nativeEvent.key === 'Enter') {
|
||||
handleCreateSpace();
|
||||
} else if (e.nativeEvent.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#f3f4f6',
|
||||
borderColor: isDark ? '#374151' : '#d1d5db',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name="add" size={16} color={isDark ? '#d1d5db' : '#4b5563'} style={styles.icon} />
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[styles.input, { color: isDark ? '#d1d5db' : '#4b5563' }]}
|
||||
className="no-focus-outline"
|
||||
placeholder="Name des neuen Space..."
|
||||
placeholderTextColor={isDark ? '#9ca3af' : '#9ca3af'}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
onKeyPress={handleKeyPress}
|
||||
// Entfernt, um doppelte Space-Erstellung zu verhindern
|
||||
// onSubmitEditing={handleCreateSpace}
|
||||
autoCapitalize="none"
|
||||
maxLength={50}
|
||||
editable={!creating}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonsContainer}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: pressed
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#d1d5db'
|
||||
: isDark
|
||||
? '#111827'
|
||||
: '#e5e7eb',
|
||||
borderColor: isDark ? '#1f2937' : '#d1d5db',
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
},
|
||||
]}
|
||||
onPress={handleCreateSpace}
|
||||
disabled={creating || !name.trim()}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={14} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.cancelButton} onPress={onCancel}>
|
||||
<Ionicons name="close" size={16} color={isDark ? '#9ca3af' : '#6b7280'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Globaler Stil für das Entfernen des Fokus-Outlines
|
||||
if (typeof document !== 'undefined') {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.no-focus-outline {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
.no-focus-outline:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 28,
|
||||
marginRight: 8,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 14,
|
||||
paddingRight: 10,
|
||||
paddingVertical: 0,
|
||||
borderRadius: 9999,
|
||||
borderWidth: 1,
|
||||
height: 28,
|
||||
minWidth: 180,
|
||||
},
|
||||
icon: {
|
||||
marginRight: 4,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
padding: 0,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
buttonsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: 4,
|
||||
},
|
||||
actionButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 9999,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 4,
|
||||
},
|
||||
cancelButton: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 9999,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
65
apps/context/apps/mobile/components/spaces/SpaceCard.tsx
Normal file
65
apps/context/apps/mobile/components/spaces/SpaceCard.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Text } from '../ui/Text';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Badge } from '../ui/Badge';
|
||||
|
||||
type SpaceCardProps = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
documentCount?: number;
|
||||
tags?: string[];
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
export const SpaceCard = ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
documentCount = 0,
|
||||
tags = [],
|
||||
onPress,
|
||||
}: SpaceCardProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handlePress = () => {
|
||||
if (onPress) {
|
||||
onPress();
|
||||
} else {
|
||||
router.push(`/spaces/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-4" onPress={handlePress}>
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-1">
|
||||
<Text variant="h3" className="mb-1">
|
||||
{name}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text variant="body" className="text-gray-600 dark:text-gray-400 mb-3">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between items-center mt-2">
|
||||
<Text variant="caption">
|
||||
{documentCount} {documentCount === 1 ? 'Dokument' : 'Dokumente'}
|
||||
</Text>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<View className="flex-row flex-wrap gap-1">
|
||||
{tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge key={index} label={tag} variant="default" />
|
||||
))}
|
||||
{tags.length > 3 && <Badge label={`+${tags.length - 3}`} variant="default" />}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
217
apps/context/apps/mobile/components/spaces/SpaceCreator.tsx
Normal file
217
apps/context/apps/mobile/components/spaces/SpaceCreator.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, TextInput, Modal, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { ThemedButton } from '~/components/ui/ThemedButton';
|
||||
import { createSpace } from '~/services/supabaseService';
|
||||
|
||||
interface SpaceCreatorProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (spaceId: string) => void;
|
||||
}
|
||||
|
||||
export const SpaceCreator: React.FC<SpaceCreatorProps> = ({ visible, onClose, onCreated }) => {
|
||||
const { isDark } = useTheme();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Funktion zum Erstellen des Space
|
||||
const handleCreateSpace = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Der Name darf nicht leer sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
const { data, error: createError } = await createSpace(
|
||||
name.trim(),
|
||||
description.trim() || undefined
|
||||
);
|
||||
|
||||
if (createError) {
|
||||
setError(`Fehler beim Erstellen des Space: ${createError.message || createError}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// Callback für erfolgreiche Erstellung
|
||||
onCreated(data.id);
|
||||
// Formular zurücksetzen
|
||||
setName('');
|
||||
setDescription('');
|
||||
// Modal schließen
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(`Unerwarteter Fehler: ${err.message}`);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Formular zurücksetzen, wenn das Modal geschlossen wird
|
||||
const handleClose = () => {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent={true} animationType="fade" onRequestClose={handleClose}>
|
||||
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={handleClose}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={[styles.modalContent, { backgroundColor: isDark ? '#1f2937' : '#ffffff' }]}
|
||||
// Verhindert, dass Klicks auf den Inhalt das Modal schließen
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, { color: isDark ? '#f9fafb' : '#111827' }]}>
|
||||
Neuen Space erstellen
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleClose}>
|
||||
<Ionicons name="close" size={24} color={isDark ? '#d1d5db' : '#4b5563'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View
|
||||
style={[styles.errorContainer, { backgroundColor: isDark ? '#7f1d1d' : '#fee2e2' }]}
|
||||
>
|
||||
<Text style={{ color: isDark ? '#fecaca' : '#991b1b' }}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: isDark ? '#d1d5db' : '#4b5563' }]}>Name</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: isDark ? '#374151' : '#f9fafb',
|
||||
color: isDark ? '#f9fafb' : '#111827',
|
||||
borderColor: isDark ? '#4b5563' : '#d1d5db',
|
||||
},
|
||||
]}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Space-Name"
|
||||
placeholderTextColor={isDark ? '#9ca3af' : '#6b7280'}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={[styles.label, { color: isDark ? '#d1d5db' : '#4b5563' }]}>
|
||||
Beschreibung (optional)
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: isDark ? '#374151' : '#f9fafb',
|
||||
color: isDark ? '#f9fafb' : '#111827',
|
||||
borderColor: isDark ? '#4b5563' : '#d1d5db',
|
||||
},
|
||||
]}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Beschreibung des Space"
|
||||
placeholderTextColor={isDark ? '#9ca3af' : '#6b7280'}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<ThemedButton
|
||||
title="Abbrechen"
|
||||
onPress={handleClose}
|
||||
variant="secondary"
|
||||
disabled={creating}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<ThemedButton
|
||||
title={creating ? 'Erstellen...' : 'Space erstellen'}
|
||||
onPress={handleCreateSpace}
|
||||
variant="primary"
|
||||
disabled={creating || !name.trim()}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
maxWidth: 500,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
marginBottom: 16,
|
||||
},
|
||||
formGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
height: 40,
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 100,
|
||||
paddingTop: 12,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 16,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { Skeleton } from '~/components/ui/Skeleton';
|
||||
import { useWindowDimensions } from 'react-native';
|
||||
|
||||
interface SpaceDetailSkeletonProps {
|
||||
documentCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton-Komponente für Space-Details während des Ladens
|
||||
*/
|
||||
export const SpaceDetailSkeleton: React.FC<SpaceDetailSkeletonProps> = ({ documentCount = 3 }) => {
|
||||
const { isDark } = useTheme();
|
||||
const { width } = useWindowDimensions();
|
||||
const isDesktop = width > 1024;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
maxWidth: isDesktop ? 800 : '100%',
|
||||
width: '100%',
|
||||
marginHorizontal: 'auto',
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
>
|
||||
{/* Space-Informationen Skeleton */}
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
{/* Titel */}
|
||||
<Skeleton width={250} height={28} style={{ marginBottom: 8 }} />
|
||||
|
||||
{/* Beschreibung */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Skeleton width={'100%'} height={16} style={{ marginBottom: 4 }} />
|
||||
<Skeleton width={'80%'} height={16} style={{ marginBottom: 4 }} />
|
||||
</View>
|
||||
|
||||
{/* Tags */}
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
|
||||
<Skeleton width={60} height={24} borderRadius={9999} />
|
||||
<Skeleton width={80} height={24} borderRadius={9999} />
|
||||
<Skeleton width={70} height={24} borderRadius={9999} />
|
||||
</View>
|
||||
|
||||
{/* Dokument-Anzahl und Bearbeiten-Button */}
|
||||
<View
|
||||
style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
>
|
||||
<Skeleton width={100} height={14} />
|
||||
<Skeleton width={32} height={32} borderRadius={16} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Skeleton width={80} height={36} borderRadius={4} style={{ marginRight: 8 }} />
|
||||
<Skeleton width={140} height={36} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
{/* Dokumenttyp-Filter Skeleton */}
|
||||
<View style={{ flexDirection: 'row', marginBottom: 16 }}>
|
||||
<Skeleton width={80} height={28} borderRadius={14} style={{ marginRight: 8 }} />
|
||||
<Skeleton width={80} height={28} borderRadius={14} style={{ marginRight: 8 }} />
|
||||
<Skeleton width={80} height={28} borderRadius={14} />
|
||||
</View>
|
||||
|
||||
{/* Dokument-Karten Skeleton */}
|
||||
{Array.from({ length: documentCount }).map((_, index) => (
|
||||
<View
|
||||
key={`document-skeleton-${index}`}
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
{/* Dokument-Typ Badge */}
|
||||
<Skeleton width={80} height={20} borderRadius={4} style={{ marginBottom: 8 }} />
|
||||
|
||||
{/* Titel */}
|
||||
<Skeleton width={'80%'} height={20} style={{ marginBottom: 12 }} />
|
||||
|
||||
{/* Inhalt */}
|
||||
<Skeleton width={'100%'} height={16} style={{ marginBottom: 4 }} />
|
||||
<Skeleton width={'90%'} height={16} style={{ marginBottom: 4 }} />
|
||||
<Skeleton width={'60%'} height={16} style={{ marginBottom: 12 }} />
|
||||
|
||||
{/* Datum und Aktionen */}
|
||||
<View
|
||||
style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}
|
||||
>
|
||||
<Skeleton width={120} height={14} />
|
||||
<Skeleton width={32} height={32} borderRadius={16} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
276
apps/context/apps/mobile/components/spaces/SpaceDropdown.tsx
Normal file
276
apps/context/apps/mobile/components/spaces/SpaceDropdown.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ScrollView, Platform } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { Space, getSpaces } from '~/services/supabaseService';
|
||||
|
||||
interface SpaceDropdownProps {
|
||||
currentSpaceId: string | null;
|
||||
onSpaceChange: (spaceId: string) => void;
|
||||
disabled?: boolean;
|
||||
openUpwards?: boolean;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
// SpaceItem als separate Komponente
|
||||
const SpaceItem = React.memo(
|
||||
({
|
||||
space,
|
||||
onSelect,
|
||||
isSelected,
|
||||
isDark,
|
||||
}: {
|
||||
space: Space;
|
||||
onSelect: () => void;
|
||||
isSelected: boolean;
|
||||
isDark: boolean;
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
// Vorberechnete Farben
|
||||
const bgColor = isSelected
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#f3f4f6'
|
||||
: isPressed
|
||||
? isDark
|
||||
? '#2d3748'
|
||||
: '#f9fafb'
|
||||
: isHovered
|
||||
? isDark
|
||||
? '#2d3748'
|
||||
: '#f9fafb'
|
||||
: isDark
|
||||
? '#1f2937'
|
||||
: '#ffffff';
|
||||
|
||||
const iconColor = isDark ? '#6366f1' : '#4f46e5';
|
||||
const textColor = isDark ? '#f9fafb' : '#111827';
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.spaceItem, { backgroundColor: bgColor }]}
|
||||
onPress={onSelect}
|
||||
onHoverIn={() => Platform.OS === 'web' && setIsHovered(true)}
|
||||
onHoverOut={() => Platform.OS === 'web' && setIsHovered(false)}
|
||||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
>
|
||||
<View style={styles.spaceItemContent}>
|
||||
<View style={styles.spaceItemHeader}>
|
||||
<View
|
||||
style={[
|
||||
styles.spaceIcon,
|
||||
{ backgroundColor: isDark ? 'rgba(99, 102, 241, 0.2)' : 'rgba(79, 70, 229, 0.1)' },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="folder-outline" size={18} color={iconColor} />
|
||||
</View>
|
||||
<Text style={[styles.spaceLabel, { color: textColor }]}>{space.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const SpaceDropdown: React.FC<SpaceDropdownProps> = ({
|
||||
currentSpaceId,
|
||||
onSpaceChange,
|
||||
disabled = false,
|
||||
openUpwards = false,
|
||||
style,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentSpace, setCurrentSpace] = useState<Space | null>(null);
|
||||
const buttonRef = useRef<View>(null);
|
||||
|
||||
// Lade alle Spaces
|
||||
useEffect(() => {
|
||||
const loadSpaces = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const spacesData = await getSpaces();
|
||||
setSpaces(spacesData);
|
||||
|
||||
// Finde den aktuellen Space
|
||||
if (currentSpaceId) {
|
||||
const space = spacesData.find((s) => s.id === currentSpaceId);
|
||||
if (space) {
|
||||
setCurrentSpace(space);
|
||||
}
|
||||
} else if (spacesData.length > 0) {
|
||||
// Wenn kein Space ausgewählt ist, zeige den ersten Space an
|
||||
setCurrentSpace(spacesData[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Spaces:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSpaces();
|
||||
}, [currentSpaceId]);
|
||||
|
||||
// Vorberechnete Farben und Styles
|
||||
const buttonBgColor = disabled
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb'
|
||||
: isPressed
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#e5e7eb'
|
||||
: isHovered
|
||||
? isDark
|
||||
? '#2d3748'
|
||||
: '#f1f2f4'
|
||||
: isDark
|
||||
? '#1f2937'
|
||||
: '#f3f4f6';
|
||||
|
||||
const iconColor = isDark ? '#6366f1' : '#4f46e5';
|
||||
const textColor = isDark ? '#f9fafb' : '#111827';
|
||||
const chevronColor = isDark ? '#9ca3af' : '#6b7280';
|
||||
|
||||
// Handler für Space-Auswahl
|
||||
const handleSpaceSelect = (spaceId: string) => {
|
||||
onSpaceChange(spaceId);
|
||||
setDropdownVisible(false);
|
||||
};
|
||||
|
||||
// Toggle-Funktion für Dropdown
|
||||
const toggleDropdown = () => {
|
||||
if (disabled) return;
|
||||
setDropdownVisible(!dropdownVisible);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]} ref={buttonRef}>
|
||||
{/* Button, der den aktuellen Space anzeigt */}
|
||||
<Pressable
|
||||
onPress={toggleDropdown}
|
||||
disabled={disabled}
|
||||
style={[
|
||||
styles.spaceButton,
|
||||
{ backgroundColor: buttonBgColor },
|
||||
disabled && { opacity: 0.6 },
|
||||
]}
|
||||
onHoverIn={() => Platform.OS === 'web' && setIsHovered(true)}
|
||||
onHoverOut={() => Platform.OS === 'web' && setIsHovered(false)}
|
||||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.spaceIcon,
|
||||
{ backgroundColor: isDark ? 'rgba(99, 102, 241, 0.2)' : 'rgba(79, 70, 229, 0.1)' },
|
||||
]}
|
||||
>
|
||||
<Ionicons name="folder-outline" size={18} color={iconColor} />
|
||||
</View>
|
||||
<Text style={[styles.spaceLabel, { color: textColor }]}>
|
||||
{currentSpace?.name || 'Space wählen'}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={dropdownVisible ? 'chevron-up' : 'chevron-down'}
|
||||
size={16}
|
||||
color={chevronColor}
|
||||
style={styles.dropdownIcon}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Dropdown für die Space-Auswahl */}
|
||||
{dropdownVisible && (
|
||||
<View
|
||||
style={[
|
||||
styles.dropdownContent,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
position: 'absolute',
|
||||
...(openUpwards ? { bottom: 40, left: 0 } : { top: 40, left: 0 }),
|
||||
width: 180, // Etwas breiter für Space-Namen
|
||||
zIndex: 5, // Moderater Z-Index
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ScrollView style={styles.spaceList} showsVerticalScrollIndicator={false}>
|
||||
{spaces.map((space) => (
|
||||
<SpaceItem
|
||||
key={space.id}
|
||||
space={space}
|
||||
onSelect={() => handleSpaceSelect(space.id)}
|
||||
isSelected={currentSpaceId === space.id}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
zIndex: 1, // Niedriger Z-Index
|
||||
},
|
||||
spaceButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
spaceIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 6,
|
||||
},
|
||||
spaceLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginRight: 4,
|
||||
},
|
||||
dropdownIcon: {
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
dropdownContent: {
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 3,
|
||||
maxHeight: 200, // Begrenzte Höhe mit Scrolling
|
||||
},
|
||||
spaceList: {
|
||||
padding: 4,
|
||||
},
|
||||
spaceItem: {
|
||||
borderRadius: 4,
|
||||
marginVertical: 2,
|
||||
},
|
||||
spaceItemContent: {
|
||||
padding: 8,
|
||||
},
|
||||
spaceItemHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
405
apps/context/apps/mobile/components/spaces/SpaceEditor.tsx.new
Normal file
405
apps/context/apps/mobile/components/spaces/SpaceEditor.tsx.new
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, TextInput, Modal, StyleSheet, TouchableOpacity, Pressable } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { ThemedButton } from '~/components/ui/ThemedButton';
|
||||
import { updateSpace, deleteSpace } from '~/services/supabaseService';
|
||||
|
||||
interface SpaceEditorProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
spaceId: string;
|
||||
spaceName: string;
|
||||
spaceDescription?: string;
|
||||
spacePrefix?: string;
|
||||
onUpdate: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export const SpaceEditor: React.FC<SpaceEditorProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
spaceId,
|
||||
spaceName,
|
||||
spaceDescription = '',
|
||||
spacePrefix = '',
|
||||
onUpdate,
|
||||
onDelete
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [name, setName] = useState(spaceName);
|
||||
const [description, setDescription] = useState(spaceDescription);
|
||||
const [prefix, setPrefix] = useState(spacePrefix);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
const [prefixError, setPrefixError] = useState<string | null>(null);
|
||||
|
||||
// Funktion zum Aktualisieren des Space
|
||||
const handleUpdateSpace = async () => {
|
||||
if (!name.trim()) {
|
||||
setError('Der Name darf nicht leer sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
// Validiere das Präfix (nur Buchstaben und Zahlen, max. 3 Zeichen)
|
||||
if (prefix && !/^[A-Za-z0-9]{1,3}$/.test(prefix)) {
|
||||
setPrefixError('Das Präfix darf nur Buchstaben und Zahlen enthalten und maximal 3 Zeichen lang sein.');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { success, error } = await updateSpace(spaceId, {
|
||||
name,
|
||||
description: description || null,
|
||||
prefix: prefix ? prefix.toUpperCase() : undefined
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
const errorMessage = error?.message || 'Unbekannter Fehler';
|
||||
setError(`Fehler beim Aktualisieren des Space: ${errorMessage}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Callback für erfolgreiche Aktualisierung
|
||||
onUpdate();
|
||||
// Schließe den Editor
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(`Unerwarteter Fehler: ${err.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Löschen des Space
|
||||
const handleDeleteSpace = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const { success, error } = await deleteSpace(spaceId);
|
||||
|
||||
if (!success) {
|
||||
const errorMessage = error?.message || 'Unbekannter Fehler';
|
||||
setError(`Fehler beim Löschen des Space: ${errorMessage}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Callback für erfolgreiches Löschen
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
// Schließe den Editor
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(`Unerwarteter Fehler: ${err.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setShowDeleteConfirmation(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.modalOverlay}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.modalContent,
|
||||
isDark && styles.modalContentDark
|
||||
]}
|
||||
// Verhindert, dass Klicks auf den Inhalt das Modal schließen
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={[
|
||||
styles.title,
|
||||
isDark && styles.titleDark
|
||||
]}>
|
||||
Space bearbeiten
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Ionicons
|
||||
name="close"
|
||||
size={24}
|
||||
color={isDark ? '#d1d5db' : '#4b5563'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[
|
||||
styles.label,
|
||||
isDark && styles.labelDark
|
||||
]}>
|
||||
Name
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isDark && styles.inputDark
|
||||
]}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Name des Space"
|
||||
placeholderTextColor={isDark ? '#666' : '#999'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[
|
||||
styles.label,
|
||||
isDark && styles.labelDark
|
||||
]}>
|
||||
Beschreibung
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
styles.textArea,
|
||||
isDark && styles.inputDark
|
||||
]}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder="Beschreibung (optional)"
|
||||
placeholderTextColor={isDark ? '#666' : '#999'}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Space-Präfix */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[
|
||||
styles.label,
|
||||
isDark && styles.labelDark
|
||||
]}>
|
||||
Space-Präfix
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isDark && styles.inputDark,
|
||||
{ textTransform: 'uppercase' }
|
||||
]}
|
||||
value={prefix}
|
||||
onChangeText={(text) => {
|
||||
setPrefix(text);
|
||||
setPrefixError(null);
|
||||
}}
|
||||
placeholder="Präfix für Dokument-IDs (z.B. M für Memoro)"
|
||||
placeholderTextColor={isDark ? '#666' : '#999'}
|
||||
maxLength={3}
|
||||
autoCapitalize="characters"
|
||||
/>
|
||||
{prefixError && (
|
||||
<Text style={styles.errorText}>{prefixError}</Text>
|
||||
)}
|
||||
<Text style={[
|
||||
styles.helperText,
|
||||
isDark && styles.helperTextDark
|
||||
]}>
|
||||
Dieses Präfix wird für die Dokument-IDs verwendet (z.B. MD1, MC2, MP3).
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<ThemedButton
|
||||
title="Speichern"
|
||||
variant="primary"
|
||||
onPress={handleUpdateSpace}
|
||||
disabled={saving}
|
||||
style={styles.saveButton}
|
||||
/>
|
||||
<ThemedButton
|
||||
title="Abbrechen"
|
||||
variant="secondary"
|
||||
onPress={onClose}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: 20, borderTopWidth: 1, borderTopColor: isDark ? '#4b5563' : '#e5e7eb', paddingTop: 20 }}>
|
||||
{!showDeleteConfirmation ? (
|
||||
<ThemedButton
|
||||
title="Space löschen"
|
||||
variant="danger"
|
||||
onPress={() => setShowDeleteConfirmation(true)}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.deleteConfirmation}>
|
||||
<Text style={styles.deleteConfirmationText}>
|
||||
Möchten Sie diesen Space wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</Text>
|
||||
<View style={styles.deleteButtons}>
|
||||
<ThemedButton
|
||||
title="Abbrechen"
|
||||
variant="secondary"
|
||||
onPress={() => setShowDeleteConfirmation(false)}
|
||||
style={{ flex: 1, marginRight: 8 }}
|
||||
/>
|
||||
<ThemedButton
|
||||
title="Löschen"
|
||||
variant="danger"
|
||||
onPress={handleDeleteSpace}
|
||||
disabled={saving}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
maxWidth: 500,
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
modalContentDark: {
|
||||
backgroundColor: '#1f2937',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
},
|
||||
titleDark: {
|
||||
color: '#f9fafb',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 5,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
marginBottom: 5,
|
||||
color: '#111827',
|
||||
fontWeight: '500',
|
||||
},
|
||||
labelDark: {
|
||||
color: '#f9fafb',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 4,
|
||||
padding: 10,
|
||||
marginBottom: 5,
|
||||
backgroundColor: '#f9fafb',
|
||||
color: '#111827',
|
||||
},
|
||||
inputDark: {
|
||||
backgroundColor: '#374151',
|
||||
color: '#f9fafb',
|
||||
borderColor: '#4b5563',
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 100,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 10,
|
||||
},
|
||||
saveButton: {
|
||||
flex: 1,
|
||||
marginRight: 10,
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: '#ef4444',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#fff',
|
||||
},
|
||||
errorContainer: {
|
||||
marginBottom: 15,
|
||||
padding: 10,
|
||||
backgroundColor: '#fee2e2',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ef4444',
|
||||
},
|
||||
errorText: {
|
||||
color: '#b91c1c',
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
marginBottom: 5,
|
||||
},
|
||||
helperText: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
marginTop: 2,
|
||||
},
|
||||
helperTextDark: {
|
||||
color: '#9ca3af',
|
||||
},
|
||||
deleteConfirmation: {
|
||||
marginTop: 20,
|
||||
padding: 15,
|
||||
backgroundColor: '#fee2e2',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ef4444',
|
||||
},
|
||||
deleteConfirmationText: {
|
||||
color: '#b91c1c',
|
||||
marginBottom: 10,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
deleteButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { FilterPill } from '~/components/ui/FilterPill';
|
||||
|
||||
interface SpaceFilterPillProps {
|
||||
id: string;
|
||||
name: string;
|
||||
isSelected: boolean;
|
||||
onPress: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const SpaceFilterPill: React.FC<SpaceFilterPillProps> = ({
|
||||
id,
|
||||
name,
|
||||
isSelected,
|
||||
onPress,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const navigateToSpace = () => {
|
||||
router.push(`/spaces/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
label={name}
|
||||
isSelected={isSelected}
|
||||
variant="space"
|
||||
onPress={() => onPress(id)}
|
||||
actionButton={{
|
||||
icon: 'chevron-forward',
|
||||
onPress: navigateToSpace,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { Skeleton } from '~/components/ui/Skeleton';
|
||||
|
||||
interface SpaceFilterPillSkeletonProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton-Komponente für Space-Filter-Pills während des Ladens
|
||||
*/
|
||||
export const SpaceFilterPillSkeleton: React.FC<SpaceFilterPillSkeletonProps> = ({ count = 3 }) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<View
|
||||
key={`space-pill-skeleton-${index}`}
|
||||
style={{
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
marginRight: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: isDark ? '#1f2937' : '#f3f4f6',
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
width={60 + (index % 3) * 20} // Verschiedene Breiten für natürlicheres Aussehen
|
||||
height={14}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
|
||||
{/* Chevron-Icon Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
marginLeft: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Skeleton width={16} height={16} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
264
apps/context/apps/mobile/components/spaces/ThemedSpaceCard.tsx
Normal file
264
apps/context/apps/mobile/components/spaces/ThemedSpaceCard.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
interface ThemedSpaceCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
documentCount: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export const ThemedSpaceCard: React.FC<ThemedSpaceCardProps> = ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
documentCount,
|
||||
tags = [],
|
||||
}) => {
|
||||
const { isDark, themeName } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
// Hilfsfunktion zum Abrufen von Theme-Farben
|
||||
const getThemeColor = (theme: string, shade: number): string => {
|
||||
if (theme === 'blue') {
|
||||
const blueColors: { [key: number]: string } = {
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
};
|
||||
return blueColors[shade] || '#3b82f6';
|
||||
} else if (theme === 'green') {
|
||||
const greenColors: { [key: number]: string } = {
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
};
|
||||
return greenColors[shade] || '#22c55e';
|
||||
} else if (theme === 'purple') {
|
||||
const purpleColors: { [key: number]: string } = {
|
||||
100: '#f3e8ff',
|
||||
200: '#e9d5ff',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
800: '#6b21a8',
|
||||
900: '#581c87',
|
||||
};
|
||||
return purpleColors[shade] || '#a855f7';
|
||||
}
|
||||
|
||||
// Fallback auf Indigo-Farben
|
||||
const indigoColors: { [key: number]: string } = {
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
};
|
||||
return indigoColors[shade] || '#6366f1';
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/spaces/${id}`)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
{
|
||||
color: isDark ? '#f9fafb' : '#111827',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="folder-outline"
|
||||
size={20}
|
||||
color={isDark ? getThemeColor(themeName, 500) : getThemeColor(themeName, 600)}
|
||||
style={styles.icon}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{description && (
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
},
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<View style={styles.tagsContainer}>
|
||||
{tags.slice(0, 3).map((tag, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.tag,
|
||||
{
|
||||
backgroundColor: isDark
|
||||
? getThemeColor(themeName, 900)
|
||||
: getThemeColor(themeName, 100),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tagText,
|
||||
{
|
||||
color: isDark ? getThemeColor(themeName, 200) : getThemeColor(themeName, 800),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{tag}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Text
|
||||
style={[
|
||||
styles.moreTag,
|
||||
{
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
},
|
||||
]}
|
||||
>
|
||||
+{tags.length - 3} mehr
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.documentCount,
|
||||
{
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{documentCount} {documentCount === 1 ? 'Dokument' : 'Dokumente'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.viewButton,
|
||||
{
|
||||
backgroundColor: isDark
|
||||
? getThemeColor(themeName, 800)
|
||||
: getThemeColor(themeName, 100),
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/spaces/${id}`)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.viewButtonText,
|
||||
{
|
||||
color: isDark ? getThemeColor(themeName, 200) : getThemeColor(themeName, 800),
|
||||
},
|
||||
]}
|
||||
>
|
||||
Öffnen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
icon: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 12,
|
||||
},
|
||||
tag: {
|
||||
borderRadius: 9999,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
moreTag: {
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
documentCount: {
|
||||
fontSize: 14,
|
||||
},
|
||||
viewButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 4,
|
||||
},
|
||||
viewButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
109
apps/context/apps/mobile/components/theme/ThemeProvider.tsx
Normal file
109
apps/context/apps/mobile/components/theme/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { themes, ThemeNames } from '~/utils/theme/colors';
|
||||
|
||||
// Typen für die Theme-Modi
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
// Interface für das Theme-Objekt
|
||||
export interface ThemeContextType {
|
||||
themeName: ThemeNames;
|
||||
mode: 'light' | 'dark';
|
||||
setTheme: (themeName: ThemeNames) => void;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
// Speicherschlüssel für Theme-Einstellungen
|
||||
const THEME_STORAGE_KEY = 'context_app_theme_settings';
|
||||
const DEFAULT_THEME: ThemeNames = 'blue';
|
||||
const DEFAULT_MODE: ThemeMode = 'system';
|
||||
|
||||
// Theme-Kontext für die Anwendung
|
||||
export const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
/**
|
||||
* Hook zum Abrufen des aktuellen Themes
|
||||
* @returns Das aktuelle Theme-Objekt
|
||||
*/
|
||||
export function useAppTheme(): ThemeContextType {
|
||||
const theme = useContext(ThemeContext);
|
||||
if (!theme) {
|
||||
throw new Error('useAppTheme muss innerhalb eines ThemeProviders verwendet werden');
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-Provider-Komponente für die Anwendung
|
||||
* Verwaltet den Theme-Zustand und bietet Funktionen zum Ändern des Themes
|
||||
*/
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const systemColorScheme = useColorScheme();
|
||||
const [themeName, setThemeName] = useState<ThemeNames>(DEFAULT_THEME);
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>(DEFAULT_MODE);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
// Lade Theme-Einstellungen aus dem AsyncStorage
|
||||
useEffect(() => {
|
||||
const loadThemeSettings = async () => {
|
||||
try {
|
||||
const storedSettings = await AsyncStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (storedSettings) {
|
||||
const { themeName, mode } = JSON.parse(storedSettings);
|
||||
setThemeName(themeName || DEFAULT_THEME);
|
||||
setThemeMode(mode || DEFAULT_MODE);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Theme-Einstellungen:', error);
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadThemeSettings();
|
||||
}, []);
|
||||
|
||||
// Speichere Theme-Einstellungen im AsyncStorage
|
||||
const saveThemeSettings = async (name: ThemeNames, mode: ThemeMode) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ themeName: name, mode }));
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern der Theme-Einstellungen:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Setze das Theme
|
||||
const setTheme = (name: ThemeNames) => {
|
||||
setThemeName(name);
|
||||
saveThemeSettings(name, themeMode);
|
||||
};
|
||||
|
||||
// Setze den Theme-Modus
|
||||
const setMode = (mode: ThemeMode) => {
|
||||
setThemeMode(mode);
|
||||
saveThemeSettings(themeName, mode);
|
||||
};
|
||||
|
||||
// Bestimme den aktuellen Modus basierend auf den Einstellungen
|
||||
const currentMode = themeMode === 'system' ? systemColorScheme || 'light' : themeMode;
|
||||
|
||||
const isDark = currentMode === 'dark';
|
||||
|
||||
// Erstelle das Theme-Objekt
|
||||
const themeContextValue: ThemeContextType = {
|
||||
themeName,
|
||||
mode: currentMode,
|
||||
setTheme,
|
||||
setMode,
|
||||
isDark,
|
||||
};
|
||||
|
||||
// Rendere den Provider nur, wenn die Theme-Einstellungen geladen wurden
|
||||
if (!isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ThemeContext.Provider value={themeContextValue}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
137
apps/context/apps/mobile/components/theme/ThemeSelector.tsx
Normal file
137
apps/context/apps/mobile/components/theme/ThemeSelector.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ViewStyle, TextStyle } from 'react-native';
|
||||
import { useAppTheme, ThemeMode } from './ThemeProvider';
|
||||
import { themes, ThemeNames } from '~/utils/theme/colors';
|
||||
import { tw } from '~/utils/theme/theme';
|
||||
|
||||
/**
|
||||
* ThemeSelector Komponente
|
||||
* Ermöglicht das Umschalten zwischen verschiedenen Themes und Modi
|
||||
*/
|
||||
export function ThemeSelector() {
|
||||
const { themeName, mode, setTheme, setMode, isDark } = useAppTheme();
|
||||
|
||||
// Theme-Optionen
|
||||
const themeOptions: { name: ThemeNames; label: string }[] = [
|
||||
{ name: 'blue', label: 'Blau' },
|
||||
{ name: 'green', label: 'Grün' },
|
||||
{ name: 'purple', label: 'Violett' },
|
||||
];
|
||||
|
||||
// Modus-Optionen
|
||||
const modeOptions: { value: ThemeMode; label: string }[] = [
|
||||
{ value: 'light', label: 'Hell' },
|
||||
{ value: 'dark', label: 'Dunkel' },
|
||||
{ value: 'system', label: 'System' },
|
||||
];
|
||||
|
||||
// Styles basierend auf dem aktuellen Theme
|
||||
const containerStyle = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 1.41,
|
||||
elevation: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const titleStyle: TextStyle = {
|
||||
color: isDark ? '#f9fafb' : '#1f2937',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 18,
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
const sectionTitleStyle: TextStyle = {
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={containerStyle.container}>
|
||||
<Text style={titleStyle}>Theme-Einstellungen</Text>
|
||||
|
||||
{/* Theme-Auswahl */}
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text style={sectionTitleStyle}>Farbschema</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||
{themeOptions.map((option) => {
|
||||
const theme = themes[option.name];
|
||||
const isSelected = themeName === option.name;
|
||||
const primaryColor = isDark ? theme.primary[400] : theme.primary[600];
|
||||
const buttonStyle: ViewStyle = {
|
||||
backgroundColor: isSelected ? primaryColor : isDark ? '#374151' : '#F3F4F6',
|
||||
borderWidth: 2,
|
||||
borderColor: isSelected ? primaryColor : 'transparent',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
minWidth: 80,
|
||||
alignItems: 'center' as const,
|
||||
};
|
||||
const textStyle = {
|
||||
color: isSelected ? (isDark ? '#FFFFFF' : '#FFFFFF') : isDark ? '#D1D5DB' : '#374151',
|
||||
fontWeight: isSelected ? ('bold' as const) : ('normal' as const),
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.name}
|
||||
style={buttonStyle}
|
||||
onPress={() => setTheme(option.name)}
|
||||
>
|
||||
<Text style={textStyle}>{option.label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Modus-Auswahl */}
|
||||
<View>
|
||||
<Text style={sectionTitleStyle}>Modus</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||
{modeOptions.map((option) => {
|
||||
const isSelected = mode === option.value;
|
||||
const buttonStyle = {
|
||||
backgroundColor: isSelected
|
||||
? isDark
|
||||
? '#6B7280'
|
||||
: '#E5E7EB'
|
||||
: isDark
|
||||
? '#374151'
|
||||
: '#F3F4F6',
|
||||
borderWidth: 2,
|
||||
borderColor: isSelected ? (isDark ? '#9CA3AF' : '#9CA3AF') : 'transparent',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
minWidth: 80,
|
||||
alignItems: 'center' as const,
|
||||
};
|
||||
const textStyle = {
|
||||
color: isDark ? '#D1D5DB' : '#374151',
|
||||
fontWeight: isSelected ? ('bold' as const) : ('normal' as const),
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={buttonStyle}
|
||||
onPress={() => setMode(option.value)}
|
||||
>
|
||||
<Text style={textStyle}>{option.label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
2
apps/context/apps/mobile/components/theme/index.ts
Normal file
2
apps/context/apps/mobile/components/theme/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './ThemeProvider';
|
||||
export * from './ThemeSelector';
|
||||
45
apps/context/apps/mobile/components/ui/Avatar.tsx
Normal file
45
apps/context/apps/mobile/components/ui/Avatar.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { View, Image, ViewProps } from 'react-native';
|
||||
import { Text } from './Text';
|
||||
|
||||
type AvatarProps = {
|
||||
name: string;
|
||||
imageUrl?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
} & ViewProps;
|
||||
|
||||
export const Avatar = ({ name, imageUrl, size = 'md', className, ...props }: AvatarProps) => {
|
||||
const sizeStyles = {
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-10 h-10',
|
||||
lg: 'w-12 h-12',
|
||||
};
|
||||
|
||||
const textSizeStyles = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
};
|
||||
|
||||
// Get initials from name
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.substring(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`${sizeStyles[size]} rounded-full overflow-hidden ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image source={{ uri: imageUrl }} className="w-full h-full" accessibilityLabel={name} />
|
||||
) : (
|
||||
<View className="w-full h-full bg-indigo-500 items-center justify-center">
|
||||
<Text className={`${textSizeStyles[size]} text-white font-medium`}>{initials}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
38
apps/context/apps/mobile/components/ui/Badge.tsx
Normal file
38
apps/context/apps/mobile/components/ui/Badge.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { View, ViewProps } from 'react-native';
|
||||
import { Text } from './Text';
|
||||
|
||||
type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
type BadgeProps = {
|
||||
label: string;
|
||||
variant?: BadgeVariant;
|
||||
} & ViewProps;
|
||||
|
||||
export const Badge = ({ label, variant = 'default', className, ...props }: BadgeProps) => {
|
||||
const variantStyles = {
|
||||
default: 'bg-gray-100 dark:bg-gray-700',
|
||||
primary: 'bg-indigo-100 dark:bg-indigo-900',
|
||||
success: 'bg-green-100 dark:bg-green-900',
|
||||
warning: 'bg-yellow-100 dark:bg-yellow-900',
|
||||
danger: 'bg-red-100 dark:bg-red-900',
|
||||
info: 'bg-blue-100 dark:bg-blue-900',
|
||||
};
|
||||
|
||||
const textStyles = {
|
||||
default: 'text-gray-800 dark:text-gray-200',
|
||||
primary: 'text-indigo-800 dark:text-indigo-200',
|
||||
success: 'text-green-800 dark:text-green-200',
|
||||
warning: 'text-yellow-800 dark:text-yellow-200',
|
||||
danger: 'text-red-800 dark:text-red-200',
|
||||
info: 'text-blue-800 dark:text-blue-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`px-2 py-1 rounded-full ${variantStyles[variant]} ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
<Text className={`text-xs font-medium ${textStyles[variant]}`}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
34
apps/context/apps/mobile/components/ui/Card.tsx
Normal file
34
apps/context/apps/mobile/components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { View, TouchableOpacity, ViewProps } from 'react-native';
|
||||
import { ReactNode } from 'react';
|
||||
import { Text } from './Text';
|
||||
|
||||
type CardProps = {
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
onPress?: () => void;
|
||||
footer?: ReactNode;
|
||||
} & ViewProps;
|
||||
|
||||
export const Card = ({ title, children, onPress, footer, className, ...props }: CardProps) => {
|
||||
const CardContainer = onPress ? TouchableOpacity : View;
|
||||
|
||||
return (
|
||||
<CardContainer
|
||||
onPress={onPress}
|
||||
className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{title && (
|
||||
<View className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<Text variant="h4">{title}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="p-4">{children}</View>
|
||||
{footer && (
|
||||
<View className="px-4 py-3 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
|
||||
{footer}
|
||||
</View>
|
||||
)}
|
||||
</CardContainer>
|
||||
);
|
||||
};
|
||||
95
apps/context/apps/mobile/components/ui/ConfirmationModal.tsx
Normal file
95
apps/context/apps/mobile/components/ui/ConfirmationModal.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Modal, StyleSheet, Pressable } from 'react-native';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { ThemedButton } from './ThemedButton';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmVariant?: 'primary' | 'secondary' | 'danger';
|
||||
}
|
||||
|
||||
export const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Bestätigen',
|
||||
cancelText = 'Abbrechen',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmVariant = 'primary',
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<Modal transparent={true} visible={visible} animationType="fade" onRequestClose={onCancel}>
|
||||
<Pressable style={styles.overlay} onPress={onCancel}>
|
||||
<View
|
||||
style={[styles.modalContainer, { backgroundColor: isDark ? '#1f2937' : '#ffffff' }]}
|
||||
// Prevent closing when clicking inside the modal
|
||||
onStartShouldSetResponder={() => true}
|
||||
onTouchEnd={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Text style={[styles.title, { color: isDark ? '#f9fafb' : '#111827' }]}>{title}</Text>
|
||||
|
||||
<Text style={[styles.message, { color: isDark ? '#e5e7eb' : '#4b5563' }]}>{message}</Text>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<ThemedButton
|
||||
title={cancelText}
|
||||
onPress={onCancel}
|
||||
variant="secondary"
|
||||
style={{ marginRight: 8, flex: 1 }}
|
||||
/>
|
||||
|
||||
<ThemedButton
|
||||
title={confirmText}
|
||||
onPress={onConfirm}
|
||||
variant={confirmVariant}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
modalContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
});
|
||||
236
apps/context/apps/mobile/components/ui/FilterPill.tsx
Normal file
236
apps/context/apps/mobile/components/ui/FilterPill.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Pressable, Text, StyleSheet, View, Platform } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
export type PillVariant = 'space' | 'document' | 'action';
|
||||
|
||||
export interface FilterPillProps {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
isSelected?: boolean;
|
||||
variant?: PillVariant;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
iconPosition?: 'left' | 'right';
|
||||
color?: {
|
||||
light: string;
|
||||
dark: string;
|
||||
};
|
||||
actionButton?: {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
};
|
||||
disabled?: boolean;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export const FilterPill: React.FC<FilterPillProps> = ({
|
||||
label,
|
||||
onPress,
|
||||
isSelected = false,
|
||||
variant = 'space',
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
color,
|
||||
actionButton,
|
||||
disabled = false,
|
||||
style,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
// Default colors based on variant
|
||||
const getDefaultColors = () => {
|
||||
switch (variant) {
|
||||
case 'space':
|
||||
return {
|
||||
light: '#818cf8',
|
||||
dark: '#4f46e5',
|
||||
};
|
||||
case 'document':
|
||||
return {
|
||||
light: '#0891b2',
|
||||
dark: '#06b6d4',
|
||||
};
|
||||
case 'action':
|
||||
return {
|
||||
light: '#4b5563',
|
||||
dark: '#d1d5db',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
light: '#818cf8',
|
||||
dark: '#4f46e5',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const pillColors = color || getDefaultColors();
|
||||
|
||||
// Calculate background color based on variant and selection state
|
||||
const getBgColor = () => {
|
||||
if (variant === 'document' && isSelected) {
|
||||
return isDark ? `${pillColors.dark}30` : `${pillColors.light}20`;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return isDark ? pillColors.dark : pillColors.light;
|
||||
}
|
||||
|
||||
// Hover and pressed states for unselected pills
|
||||
if (isPressed) {
|
||||
return isDark ? '#374151' : '#e5e7eb';
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
return isDark ? '#2d3748' : '#f1f2f4';
|
||||
}
|
||||
|
||||
return isDark ? '#1f2937' : '#f3f4f6';
|
||||
};
|
||||
|
||||
// Calculate text/icon color based on variant and selection state
|
||||
const getContentColor = () => {
|
||||
if (variant === 'document' && isSelected) {
|
||||
return isDark ? pillColors.dark : pillColors.light;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
return isDark ? '#d1d5db' : '#4b5563';
|
||||
};
|
||||
|
||||
// Calculate border color
|
||||
const getBorderColor = () => {
|
||||
if (variant === 'document' && isSelected) {
|
||||
return isDark ? pillColors.dark : pillColors.light;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return isDark ? pillColors.dark : pillColors.light;
|
||||
}
|
||||
|
||||
return isDark ? '#374151' : '#d1d5db';
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: actionButton ? 12 : 8,
|
||||
height: 28,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.pill,
|
||||
{
|
||||
paddingRight: actionButton ? 30 : 14, // Mehr Platz für den Action Button
|
||||
backgroundColor: getBgColor(),
|
||||
borderColor: getBorderColor(),
|
||||
opacity: disabled ? 0.6 : (isHovered || isPressed) && !isSelected ? 0.8 : 1,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
onPress={disabled ? undefined : onPress}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
>
|
||||
{icon && iconPosition === 'left' && (
|
||||
<Ionicons name={icon} size={16} color={getContentColor()} style={styles.leftIcon} />
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: getContentColor(),
|
||||
fontSize: 14,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
{icon && iconPosition === 'right' && (
|
||||
<Ionicons name={icon} size={16} color={getContentColor()} style={styles.rightIcon} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{actionButton && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: isSelected
|
||||
? isDark
|
||||
? pillColors.dark
|
||||
: pillColors.light
|
||||
: pressed
|
||||
? isDark
|
||||
? '#374151'
|
||||
: '#d1d5db'
|
||||
: isDark
|
||||
? '#111827'
|
||||
: '#e5e7eb',
|
||||
borderColor: isSelected
|
||||
? isDark
|
||||
? pillColors.dark
|
||||
: pillColors.light
|
||||
: isDark
|
||||
? '#1f2937'
|
||||
: '#d1d5db',
|
||||
opacity: disabled ? 0.6 : (pressed ? 0.8 : 1),
|
||||
},
|
||||
]}
|
||||
onPress={disabled ? undefined : actionButton.onPress}
|
||||
>
|
||||
<Ionicons
|
||||
name={actionButton.icon}
|
||||
size={14}
|
||||
color={isSelected ? '#ffffff' : isDark ? '#d1d5db' : '#4b5563'}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 14,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 9999,
|
||||
borderWidth: 1,
|
||||
height: 28,
|
||||
},
|
||||
label: {
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
},
|
||||
leftIcon: {
|
||||
marginRight: 4,
|
||||
},
|
||||
rightIcon: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
actionButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 9999,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: -10, // Weniger Überlappung für mehr Abstand
|
||||
position: 'absolute',
|
||||
right: -2, // Mehr Abstand nach rechts
|
||||
},
|
||||
});
|
||||
33
apps/context/apps/mobile/components/ui/Input.tsx
Normal file
33
apps/context/apps/mobile/components/ui/Input.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { TextInput, TextInputProps, View } from 'react-native';
|
||||
import { forwardRef } from 'react';
|
||||
import { Text } from './Text';
|
||||
|
||||
type InputProps = {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helper?: string;
|
||||
} & TextInputProps;
|
||||
|
||||
export const Input = forwardRef<TextInput, InputProps>(
|
||||
({ label, error, helper, className, ...props }, ref) => {
|
||||
return (
|
||||
<View className="mb-4">
|
||||
{label && (
|
||||
<Text variant="label" className="mb-1">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<TextInput
|
||||
ref={ref}
|
||||
className={`bg-white dark:bg-gray-800 border rounded-lg p-3 text-gray-900 dark:text-white ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
} ${className || ''}`}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
{...props}
|
||||
/>
|
||||
{error && <Text className="text-red-500 text-xs mt-1">{error}</Text>}
|
||||
{helper && !error && <Text className="text-gray-500 text-xs mt-1">{helper}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
193
apps/context/apps/mobile/components/ui/LoadingScreen.tsx
Normal file
193
apps/context/apps/mobile/components/ui/LoadingScreen.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet, Modal, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from './Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface LoadingScreenProps {
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
progress?: {
|
||||
current: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
};
|
||||
showSpinner?: boolean;
|
||||
icon?: {
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const LoadingScreen: React.FC<LoadingScreenProps> = ({
|
||||
visible,
|
||||
title = 'Wird geladen...',
|
||||
message,
|
||||
progress,
|
||||
showSpinner = true,
|
||||
icon,
|
||||
}) => {
|
||||
const { mode, colors } = useTheme();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
// Berechne den Fortschritt als Prozentsatz
|
||||
const progressPercentage = progress ? Math.round((progress.current / progress.total) * 100) : 0;
|
||||
|
||||
// Generiere ein Label für den Fortschritt, wenn keines angegeben wurde
|
||||
const progressLabel =
|
||||
progress?.label || (progress ? `${progress.current} von ${progress.total}` : '');
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent={true} animationType="fade">
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.5)' },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.content,
|
||||
{
|
||||
backgroundColor: isDark ? colors.gray[800] : colors.gray[50],
|
||||
borderColor: isDark ? colors.gray[700] : colors.gray[200],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Icon (optional) */}
|
||||
{icon && (
|
||||
<Ionicons
|
||||
name={icon.name as any}
|
||||
size={icon.size || 48}
|
||||
color={icon.color || (isDark ? colors.primary[400] : colors.primary[500])}
|
||||
style={styles.icon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Titel */}
|
||||
<Text style={[styles.title, { color: isDark ? colors.gray[100] : colors.gray[900] }]}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Nachricht (optional) */}
|
||||
{message && (
|
||||
<Text style={[styles.message, { color: isDark ? colors.gray[300] : colors.gray[600] }]}>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Spinner (optional) */}
|
||||
{showSpinner && !progress && (
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={isDark ? colors.primary[400] : colors.primary[500]}
|
||||
style={styles.spinner}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fortschrittsanzeige (optional) */}
|
||||
{progress && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressLabelContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.progressLabel,
|
||||
{ color: isDark ? colors.gray[300] : colors.gray[600] },
|
||||
]}
|
||||
>
|
||||
{progressLabel}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.progressPercentage,
|
||||
{ color: isDark ? colors.gray[300] : colors.gray[600] },
|
||||
]}
|
||||
>
|
||||
{progressPercentage}%
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{ backgroundColor: isDark ? colors.gray[700] : colors.gray[200] },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
backgroundColor: isDark ? colors.primary[500] : colors.primary[600],
|
||||
width: `${progressPercentage}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
content: {
|
||||
width: '90%',
|
||||
maxWidth: 400,
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
icon: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
spinner: {
|
||||
marginTop: 16,
|
||||
},
|
||||
progressContainer: {
|
||||
width: '100%',
|
||||
marginTop: 16,
|
||||
},
|
||||
progressLabelContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
progressLabel: {
|
||||
fontSize: 14,
|
||||
},
|
||||
progressPercentage: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
progressBar: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
188
apps/context/apps/mobile/components/ui/QuickStartCard.tsx
Normal file
188
apps/context/apps/mobile/components/ui/QuickStartCard.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
|
||||
interface QuickStartItemProps {
|
||||
title: string;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface QuickStartCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
items: QuickStartItemProps[];
|
||||
}
|
||||
|
||||
const QuickStartItem: React.FC<QuickStartItemProps> = ({ title, icon, onPress }) => {
|
||||
const { isDark, themeName } = useTheme();
|
||||
|
||||
// Hilfsfunktion zum Abrufen von Theme-Farben
|
||||
const getThemeColor = (theme: string, shade: number): string => {
|
||||
if (theme === 'blue') {
|
||||
const blueColors: { [key: number]: string } = {
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
};
|
||||
return blueColors[shade] || '#3b82f6';
|
||||
} else if (theme === 'green') {
|
||||
const greenColors: { [key: number]: string } = {
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
};
|
||||
return greenColors[shade] || '#22c55e';
|
||||
} else if (theme === 'purple') {
|
||||
const purpleColors: { [key: number]: string } = {
|
||||
100: '#f3e8ff',
|
||||
200: '#e9d5ff',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
800: '#6b21a8',
|
||||
900: '#581c87',
|
||||
};
|
||||
return purpleColors[shade] || '#a855f7';
|
||||
}
|
||||
|
||||
// Fallback auf Indigo-Farben
|
||||
const indigoColors: { [key: number]: string } = {
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
};
|
||||
return indigoColors[shade] || '#6366f1';
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.item,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{icon && (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={24}
|
||||
color={isDark ? getThemeColor(themeName, 500) : getThemeColor(themeName, 600)}
|
||||
style={styles.itemIcon}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.itemTitle,
|
||||
{
|
||||
color: isDark ? '#f9fafb' : '#111827',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuickStartCard: React.FC<QuickStartCardProps> = ({ title, description, items }) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
{
|
||||
color: isDark ? '#f9fafb' : '#111827',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{
|
||||
color: isDark ? '#d1d5db' : '#4b5563',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
<View style={styles.itemsContainer}>
|
||||
{items.map((item, index) => (
|
||||
<QuickStartItem key={index} title={item.title} icon={item.icon} onPress={item.onPress} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
marginBottom: 16,
|
||||
},
|
||||
itemsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginHorizontal: -8,
|
||||
},
|
||||
item: {
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginHorizontal: 8,
|
||||
marginBottom: 16,
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
},
|
||||
itemIcon: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
itemTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
119
apps/context/apps/mobile/components/ui/SaveIndicator.tsx
Normal file
119
apps/context/apps/mobile/components/ui/SaveIndicator.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from './Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
export interface SaveIndicatorProps {
|
||||
status: SaveStatus;
|
||||
lastSaved?: Date | null;
|
||||
error?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konsistente Save-Status-Anzeige für den Dokumenten-Editor
|
||||
*/
|
||||
export const SaveIndicator: React.FC<SaveIndicatorProps> = ({
|
||||
status,
|
||||
lastSaved,
|
||||
error,
|
||||
className,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'saving':
|
||||
return {
|
||||
text: 'Speichert...',
|
||||
color: isDark ? '#60a5fa' : '#3b82f6',
|
||||
icon: null,
|
||||
showSpinner: true,
|
||||
};
|
||||
case 'saved':
|
||||
return {
|
||||
text: 'Gespeichert',
|
||||
color: isDark ? '#34d399' : '#10b981',
|
||||
icon: 'checkmark-circle' as const,
|
||||
showSpinner: false,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
text: error || 'Fehler beim Speichern',
|
||||
color: isDark ? '#f87171' : '#ef4444',
|
||||
icon: 'alert-circle' as const,
|
||||
showSpinner: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: 'Ungespeichert',
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
icon: null,
|
||||
showSpinner: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
|
||||
const formatLastSaved = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (seconds < 60) {
|
||||
return 'gerade eben';
|
||||
} else if (minutes < 60) {
|
||||
return `vor ${minutes} Min`;
|
||||
} else if (hours < 24) {
|
||||
return `vor ${hours} Std`;
|
||||
} else {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`flex-row items-center ${className}`}>
|
||||
{config.showSpinner && (
|
||||
<ActivityIndicator size="small" color={config.color} style={{ marginRight: 8 }} />
|
||||
)}
|
||||
|
||||
{config.icon && (
|
||||
<Ionicons name={config.icon} size={16} color={config.color} style={{ marginRight: 8 }} />
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: config.color,
|
||||
fontWeight: status === 'error' ? '600' : '400',
|
||||
}}
|
||||
>
|
||||
{config.text}
|
||||
</Text>
|
||||
|
||||
{status === 'saved' && lastSaved && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{formatLastSaved(lastSaved)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
92
apps/context/apps/mobile/components/ui/Skeleton.tsx
Normal file
92
apps/context/apps/mobile/components/ui/Skeleton.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import React from 'react';
|
||||
import { View, ViewStyle, DimensionValue } from 'react-native';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: DimensionValue;
|
||||
height?: DimensionValue;
|
||||
borderRadius?: number;
|
||||
style?: ViewStyle;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton-Komponente für Ladezustände
|
||||
* Zeigt einen pulsierenden Platzhalter an, während Inhalte geladen werden
|
||||
*/
|
||||
export const Skeleton: React.FC<SkeletonProps> = ({
|
||||
width = '100%',
|
||||
height = 16,
|
||||
borderRadius = 4,
|
||||
style,
|
||||
animated = true,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const backgroundColor = isDark ? '#374151' : '#e5e7eb';
|
||||
|
||||
// Animation-Styling
|
||||
const [opacity, setOpacity] = React.useState(0.7);
|
||||
|
||||
// Einfache Pulsier-Animation
|
||||
React.useEffect(() => {
|
||||
if (!animated) return;
|
||||
|
||||
let interval: NodeJS.Timeout;
|
||||
let increasing = false;
|
||||
|
||||
interval = setInterval(() => {
|
||||
setOpacity((prevOpacity) => {
|
||||
if (prevOpacity >= 0.9) {
|
||||
increasing = false;
|
||||
} else if (prevOpacity <= 0.5) {
|
||||
increasing = true;
|
||||
}
|
||||
return increasing ? prevOpacity + 0.02 : prevOpacity - 0.02;
|
||||
});
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [animated]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
backgroundColor,
|
||||
opacity,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Skeleton-Text-Komponente für Text-Ladezustände
|
||||
* Zeigt mehrere Zeilen von Skeleton-Elementen an
|
||||
*/
|
||||
export const SkeletonText: React.FC<{
|
||||
lines?: number;
|
||||
lineHeight?: number;
|
||||
spacing?: number;
|
||||
width?: DimensionValue[];
|
||||
style?: ViewStyle;
|
||||
animated?: boolean;
|
||||
}> = ({ lines = 3, lineHeight = 16, spacing = 8, width = ['100%'], style, animated = true }) => {
|
||||
return (
|
||||
<View style={style}>
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
height={lineHeight}
|
||||
width={width[index % width.length]}
|
||||
style={{ marginBottom: index < lines - 1 ? spacing : 0 }}
|
||||
animated={animated}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
25
apps/context/apps/mobile/components/ui/Text.tsx
Normal file
25
apps/context/apps/mobile/components/ui/Text.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Text as RNText, TextProps as RNTextProps } from 'react-native';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type TextProps = {
|
||||
variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'caption' | 'label';
|
||||
children: ReactNode;
|
||||
} & RNTextProps;
|
||||
|
||||
export const Text = ({ variant = 'body', children, className, ...props }: TextProps) => {
|
||||
const variantStyles = {
|
||||
h1: 'text-3xl font-bold text-gray-900 dark:text-white',
|
||||
h2: 'text-2xl font-bold text-gray-900 dark:text-white',
|
||||
h3: 'text-xl font-semibold text-gray-900 dark:text-white',
|
||||
h4: 'text-lg font-semibold text-gray-900 dark:text-white',
|
||||
body: 'text-base text-gray-700 dark:text-gray-300',
|
||||
caption: 'text-sm text-gray-500 dark:text-gray-400',
|
||||
label: 'text-sm font-medium text-gray-700 dark:text-gray-300',
|
||||
};
|
||||
|
||||
return (
|
||||
<RNText className={`${variantStyles[variant]} ${className || ''}`} {...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
};
|
||||
286
apps/context/apps/mobile/components/ui/ThemedButton.tsx
Normal file
286
apps/context/apps/mobile/components/ui/ThemedButton.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Pressable, Text, StyleSheet, ViewStyle, TextStyle, Platform } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
import { colors } from '~/utils/theme/colors';
|
||||
|
||||
interface ThemedButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
iconName?: keyof typeof Ionicons.glyphMap;
|
||||
isActive?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: ViewStyle;
|
||||
textStyle?: TextStyle;
|
||||
iconOnly?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const ThemedButton: React.FC<ThemedButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
iconName,
|
||||
isActive = false,
|
||||
disabled = false,
|
||||
style,
|
||||
textStyle,
|
||||
iconOnly = false,
|
||||
tooltip,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
// Bestimme die Hintergrundfarbe basierend auf Variante und Zustand
|
||||
const getBackgroundColor = () => {
|
||||
// Typensicherheit für die Variante
|
||||
if (variant === 'outline') {
|
||||
return 'transparent';
|
||||
}
|
||||
|
||||
// Danger-Variante für Lösch-Buttons
|
||||
if (variant === 'danger') {
|
||||
if (disabled) {
|
||||
return isDark ? '#6b2128' : '#fecaca';
|
||||
}
|
||||
|
||||
if (isPressed || isActive) {
|
||||
return isDark ? '#b91c1c' : '#ef4444';
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
return isDark ? '#991b1b' : '#f87171';
|
||||
}
|
||||
|
||||
return isDark ? '#7f1d1d' : '#dc2626';
|
||||
}
|
||||
|
||||
const buttonColors = colors.button[variant as 'primary' | 'secondary'];
|
||||
|
||||
if (disabled) {
|
||||
return isDark
|
||||
? buttonColors.background.disabled.dark
|
||||
: buttonColors.background.disabled.light;
|
||||
}
|
||||
|
||||
if (isPressed || isActive) {
|
||||
return isDark ? buttonColors.background.active.dark : buttonColors.background.active.light;
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
return isDark ? buttonColors.background.hover.dark : buttonColors.background.hover.light;
|
||||
}
|
||||
|
||||
return isDark ? buttonColors.background.default.dark : buttonColors.background.default.light;
|
||||
};
|
||||
|
||||
// Bestimme die Textfarbe basierend auf Variante und Zustand
|
||||
const getTextColor = () => {
|
||||
// Danger-Variante für Lösch-Buttons
|
||||
if (variant === 'danger') {
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
const buttonColors = colors.button[variant as keyof typeof colors.button];
|
||||
|
||||
if (disabled) {
|
||||
if (variant === 'primary' || variant === 'secondary') {
|
||||
return isDark ? buttonColors.text.disabled.dark : buttonColors.text.disabled.light;
|
||||
}
|
||||
return isDark ? '#4b5563' : '#9ca3af';
|
||||
}
|
||||
|
||||
if (isPressed || isActive) {
|
||||
if (variant === 'secondary') {
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
if (variant === 'primary') {
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
// outline variant
|
||||
return isDark ? '#e5e7eb' : '#374151';
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
if (variant === 'outline') {
|
||||
return isDark ? '#e5e7eb' : '#374151';
|
||||
}
|
||||
|
||||
if (variant === 'primary') {
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
if (variant === 'secondary') {
|
||||
return isDark ? '#f9fafb' : '#111827';
|
||||
}
|
||||
}
|
||||
|
||||
// Default state
|
||||
if (variant === 'primary') {
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
if (variant === 'secondary') {
|
||||
return isDark ? '#f9fafb' : '#111827';
|
||||
}
|
||||
|
||||
// outline variant
|
||||
return isDark ? '#f9fafb' : '#111827';
|
||||
};
|
||||
|
||||
// Bestimme den Rand basierend auf Variante und Zustand
|
||||
const getBorderColor = () => {
|
||||
if (variant === 'outline') {
|
||||
const borderColors = colors.button.outline.border;
|
||||
|
||||
if (isPressed || isActive) {
|
||||
return isDark ? borderColors.active.dark : borderColors.active.light;
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
return isDark ? borderColors.hover.dark : borderColors.hover.light;
|
||||
}
|
||||
|
||||
return isDark ? borderColors.default.dark : borderColors.default.light;
|
||||
}
|
||||
return 'transparent';
|
||||
};
|
||||
|
||||
// Bestimme die Größe basierend auf der size-Prop und iconOnly
|
||||
const getSizeStyles = () => {
|
||||
if (iconOnly) {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return {
|
||||
padding: 6,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
iconSize: 14,
|
||||
};
|
||||
case 'large':
|
||||
return {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 16,
|
||||
iconSize: 20,
|
||||
};
|
||||
case 'medium':
|
||||
default:
|
||||
return {
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
iconSize: 16,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
iconSize: 14,
|
||||
};
|
||||
case 'large':
|
||||
return {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
fontSize: 16,
|
||||
iconSize: 20,
|
||||
};
|
||||
case 'medium':
|
||||
default:
|
||||
return {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
iconSize: 16,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sizeStyles = getSizeStyles();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
onHoverIn={() => Platform.OS === 'web' && setIsHovered(true)}
|
||||
onHoverOut={() => Platform.OS === 'web' && setIsHovered(false)}
|
||||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
accessibilityLabel={title}
|
||||
accessibilityHint={tooltip}
|
||||
style={[
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: getBackgroundColor(),
|
||||
borderRadius: sizeStyles.borderRadius,
|
||||
borderWidth: variant === 'outline' ? 1 : 0,
|
||||
borderColor: getBorderColor(),
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
},
|
||||
iconOnly
|
||||
? {
|
||||
padding: sizeStyles.padding,
|
||||
aspectRatio: 1, // Quadratisch für Icon-Only-Buttons
|
||||
justifyContent: 'center',
|
||||
}
|
||||
: {
|
||||
paddingVertical: sizeStyles.paddingVertical,
|
||||
paddingHorizontal: sizeStyles.paddingHorizontal,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{iconName && (
|
||||
<Ionicons
|
||||
name={iconName}
|
||||
size={sizeStyles.iconSize}
|
||||
color={getTextColor()}
|
||||
style={[styles.icon, iconOnly && { marginRight: 0 }]}
|
||||
/>
|
||||
)}
|
||||
{!iconOnly && (
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
{
|
||||
color: getTextColor(),
|
||||
fontSize: sizeStyles.fontSize,
|
||||
fontWeight: '500',
|
||||
},
|
||||
textStyle,
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
icon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
text: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
79
apps/context/apps/mobile/components/ui/ThemedCard.tsx
Normal file
79
apps/context/apps/mobile/components/ui/ThemedCard.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ViewStyle, TextStyle, StyleProp } from 'react-native';
|
||||
import { Text } from './Text';
|
||||
import { useTheme } from '~/utils/theme/theme';
|
||||
|
||||
interface ThemedCardProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
onPress?: () => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
titleStyle?: StyleProp<TextStyle>;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
export const ThemedCard: React.FC<ThemedCardProps> = ({
|
||||
children,
|
||||
title,
|
||||
onPress,
|
||||
style,
|
||||
titleStyle,
|
||||
contentStyle,
|
||||
}) => {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const CardContainer = onPress ? TouchableOpacity : View;
|
||||
|
||||
return (
|
||||
<CardContainer
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
style,
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={onPress ? 0.7 : undefined}
|
||||
>
|
||||
{title && (
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
{
|
||||
color: isDark ? '#f9fafb' : '#111827',
|
||||
},
|
||||
titleStyle,
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<View style={[styles.content, contentStyle]}>{children}</View>
|
||||
</CardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e5e7eb',
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
483
apps/context/apps/mobile/components/variants/VariantCreator.tsx
Normal file
483
apps/context/apps/mobile/components/variants/VariantCreator.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Alert,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { Card } from '~/components/ui/Card';
|
||||
import { Button } from '~/components/Button';
|
||||
import { useTheme, useThemeClasses, twMerge } from '~/utils/theme';
|
||||
import { createDocument } from '~/services/supabaseService';
|
||||
|
||||
type VariantCreatorProps = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
documentContent: string;
|
||||
documentTitle?: string;
|
||||
documentId?: string;
|
||||
spaceId: string;
|
||||
onVariantCreated?: (newDocumentId: string) => void;
|
||||
};
|
||||
|
||||
type VariantOption = {
|
||||
original: string;
|
||||
selected: string;
|
||||
allOptions: string[];
|
||||
position: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const VariantCreator: React.FC<VariantCreatorProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
documentContent = '',
|
||||
documentTitle = '',
|
||||
documentId = '',
|
||||
spaceId,
|
||||
onVariantCreated,
|
||||
}) => {
|
||||
const [variantOptions, setVariantOptions] = useState<VariantOption[]>([]);
|
||||
const [previewContent, setPreviewContent] = useState(documentContent);
|
||||
const [isCreatingVariant, setIsCreatingVariant] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const { mode, themeName, colors } = useTheme();
|
||||
const themeClasses = useThemeClasses();
|
||||
const isDark = mode === 'dark';
|
||||
|
||||
// Varianten aus dem Dokument extrahieren
|
||||
useEffect(() => {
|
||||
if (visible && documentContent) {
|
||||
// Extrahiere Varianten aus dem Markdown-Text
|
||||
const extractedVariants = extractVariantsFromMarkdown(documentContent);
|
||||
|
||||
// Initialisiere mit den gefundenen Varianten
|
||||
setVariantOptions(extractedVariants);
|
||||
|
||||
setPreviewContent(documentContent);
|
||||
}
|
||||
}, [visible, documentContent]);
|
||||
|
||||
// Extrahiere Varianten aus dem Markdown-Text
|
||||
const extractVariantsFromMarkdown = (text: string): VariantOption[] => {
|
||||
const variants: VariantOption[] = [];
|
||||
|
||||
// Suche nach Varianten in eckigen Klammern mit Kommas als Trennzeichen
|
||||
// Format: [Option1, Option2, Option3]
|
||||
const variantRegex = /\[(.*?)\]/g;
|
||||
let match;
|
||||
|
||||
while ((match = variantRegex.exec(text)) !== null) {
|
||||
const fullMatch = match[0]; // z.B. "[Heilbronn, München, Hamburg]"
|
||||
const optionsString = match[1]; // z.B. "Heilbronn, München, Hamburg"
|
||||
const options = optionsString.split(',').map((option) => option.trim());
|
||||
|
||||
if (options.length > 0) {
|
||||
variants.push({
|
||||
original: fullMatch,
|
||||
selected: options[0], // Die erste Option ist standardmäßig ausgewählt
|
||||
allOptions: options,
|
||||
position: {
|
||||
start: match.index,
|
||||
end: match.index + fullMatch.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return variants;
|
||||
};
|
||||
|
||||
// Aktualisiere die ausgewählte Option für eine Variante
|
||||
const updateSelectedOption = (index: number, selected: string) => {
|
||||
const updatedOptions = [...variantOptions];
|
||||
updatedOptions[index] = {
|
||||
...updatedOptions[index],
|
||||
selected,
|
||||
};
|
||||
setVariantOptions(updatedOptions);
|
||||
|
||||
// Aktualisiere die Vorschau
|
||||
updatePreview(updatedOptions);
|
||||
};
|
||||
|
||||
// Aktualisiere die Vorschau mit den ausgewählten Varianten
|
||||
const updatePreview = (options: VariantOption[] = variantOptions) => {
|
||||
let updatedContent = documentContent;
|
||||
|
||||
// Sortiere die Varianten nach Position (von hinten nach vorne),
|
||||
// damit die Indizes nicht durcheinander kommen, wenn wir Text ersetzen
|
||||
const sortedOptions = [...options].sort((a, b) => b.position.start - a.position.start);
|
||||
|
||||
// Ersetze alle Varianten mit der ausgewählten Option
|
||||
sortedOptions.forEach(({ original, selected, position }) => {
|
||||
updatedContent =
|
||||
updatedContent.substring(0, position.start) +
|
||||
selected +
|
||||
updatedContent.substring(position.end);
|
||||
});
|
||||
|
||||
setPreviewContent(updatedContent);
|
||||
};
|
||||
|
||||
// Erstelle eine neue Variante des Dokuments
|
||||
const createVariant = async () => {
|
||||
if (!documentId) {
|
||||
Alert.alert(
|
||||
'Fehler',
|
||||
'Das Dokument muss zuerst gespeichert werden, bevor eine Variante erstellt werden kann.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe, ob mindestens eine Variante definiert wurde
|
||||
if (variantOptions.length === 0) {
|
||||
Alert.alert(
|
||||
'Hinweis',
|
||||
'Keine Varianten im Dokument gefunden. Bitte fügen Sie Varianten im Format [Option1, Option2, Option3] hinzu.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingVariant(true);
|
||||
|
||||
try {
|
||||
// Erstelle einen neuen Titel basierend auf den Varianten
|
||||
let variantTitle = documentTitle || 'Unbenanntes Dokument';
|
||||
const variantSummary = variantOptions.map((vo) => vo.selected).join(', ');
|
||||
|
||||
if (variantSummary) {
|
||||
variantTitle += ` (Variante: ${variantSummary})`;
|
||||
}
|
||||
|
||||
// Erstelle das neue Dokument
|
||||
const { data, error } = await createDocument(
|
||||
previewContent,
|
||||
'text', // Verwende den Typ 'text' für Varianten (früher 'generated')
|
||||
spaceId,
|
||||
{
|
||||
title: variantTitle,
|
||||
metadata: {
|
||||
variantOf: documentId,
|
||||
variants: variantOptions.map((vo) => ({
|
||||
original: vo.original,
|
||||
selected: vo.selected,
|
||||
allOptions: vo.allOptions,
|
||||
})),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Fehler', `Fehler beim Erstellen der Variante: ${error}`);
|
||||
} else if (data) {
|
||||
// Erfolgsmeldung anzeigen
|
||||
Alert.alert('Erfolg', 'Neue Dokumentvariante wurde erstellt!', [{ text: 'OK' }]);
|
||||
|
||||
// Callback aufrufen, wenn vorhanden
|
||||
if (onVariantCreated) {
|
||||
onVariantCreated(data.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Variante:', error);
|
||||
Alert.alert('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setIsCreatingVariant(false);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Füge eine neue Variante zum Dokument hinzu
|
||||
const addVariantToDocument = () => {
|
||||
Alert.alert(
|
||||
'Variante hinzufügen',
|
||||
'Fügen Sie eine Variante im Format [Option1, Option2, Option3] direkt im Dokument hinzu und öffnen Sie den Varianten-Editor erneut.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
};
|
||||
|
||||
// Generiere alle möglichen Kombinationen von Varianten
|
||||
const generateAllCombinations = () => {
|
||||
if (variantOptions.length === 0) {
|
||||
Alert.alert('Hinweis', 'Keine Varianten im Dokument gefunden.');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Alle Kombinationen generieren',
|
||||
`Dies wird ${calculateTotalCombinations()} Dokumente erstellen. Fortfahren?`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{ text: 'Fortfahren', onPress: createAllCombinations },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Berechne die Gesamtzahl der möglichen Kombinationen
|
||||
const calculateTotalCombinations = (): number => {
|
||||
return variantOptions.reduce((total, variant) => total * variant.allOptions.length, 1);
|
||||
};
|
||||
|
||||
// Erstelle alle möglichen Kombinationen von Varianten
|
||||
const createAllCombinations = async () => {
|
||||
setIsCreatingVariant(true);
|
||||
|
||||
try {
|
||||
// Implementierung würde hier alle Kombinationen erstellen
|
||||
// Dies ist eine vereinfachte Version, die nur eine Meldung anzeigt
|
||||
Alert.alert(
|
||||
'Information',
|
||||
'Diese Funktion wird in einer zukünftigen Version implementiert.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Kombinationen:', error);
|
||||
Alert.alert('Fehler', 'Ein unerwarteter Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setIsCreatingVariant(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent={true} animationType="slide" onRequestClose={onClose}>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContainer,
|
||||
{ backgroundColor: isDark ? 'rgba(0, 0, 0, 0.8)' : 'rgba(0, 0, 0, 0.5)' },
|
||||
]}
|
||||
>
|
||||
<Card
|
||||
className={twMerge('p-0 rounded-lg w-full max-w-xl', isDark ? 'bg-gray-800' : 'bg-white')}
|
||||
style={styles.modalContent}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
className={twMerge(
|
||||
'flex-row justify-between items-center p-4 border-b',
|
||||
isDark ? 'border-gray-700' : 'border-gray-200'
|
||||
)}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons
|
||||
name="git-branch-outline"
|
||||
size={24}
|
||||
color={isDark ? colors.gray[300] : colors.gray[700]}
|
||||
/>
|
||||
<Text className="text-xl font-semibold ml-2">Dokumentvariante erstellen</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Ionicons
|
||||
name="close-outline"
|
||||
size={28}
|
||||
color={isDark ? colors.gray[300] : colors.gray[700]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView className="p-4">
|
||||
<Text className="text-base mb-2">
|
||||
Erstellen Sie eine Variante dieses Dokuments, indem Sie bestimmte Wörter ersetzen.
|
||||
</Text>
|
||||
|
||||
{/* Schlüsselwörter und Ersetzungen */}
|
||||
<View className="mb-4">
|
||||
<Text className="text-lg font-semibold mb-2">Gefundene Varianten</Text>
|
||||
|
||||
{variantOptions.length === 0 ? (
|
||||
<View>
|
||||
<Text className="italic mb-4">Keine Varianten gefunden.</Text>
|
||||
<Text className="mb-2">
|
||||
Fügen Sie Varianten im Format [Option1, Option2, Option3] in Ihrem Dokument
|
||||
hinzu.
|
||||
</Text>
|
||||
<Text className="mb-4">
|
||||
Beispiel: "Neckar fließt vorbei, Reben grünen am Hügel, [Heilbronn, München,
|
||||
Hamburg] blüht im Licht."
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
variantOptions.map((vo, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className="mb-4 p-3 border rounded-md"
|
||||
style={{ borderColor: isDark ? '#4b5563' : '#d1d5db' }}
|
||||
>
|
||||
<Text className="font-semibold mb-2">
|
||||
Variante {index + 1}: {vo.original}
|
||||
</Text>
|
||||
|
||||
<Text className="mb-2">Wählen Sie eine Option:</Text>
|
||||
|
||||
{vo.allOptions.map((option, optionIndex) => (
|
||||
<TouchableOpacity
|
||||
key={optionIndex}
|
||||
onPress={() => updateSelectedOption(index, option)}
|
||||
className={twMerge(
|
||||
'flex-row items-center p-2 rounded-md mb-1',
|
||||
vo.selected === option
|
||||
? isDark
|
||||
? 'bg-indigo-700'
|
||||
: 'bg-indigo-100'
|
||||
: isDark
|
||||
? 'bg-gray-700'
|
||||
: 'bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<Ionicons
|
||||
name={vo.selected === option ? 'radio-button-on' : 'radio-button-off'}
|
||||
size={20}
|
||||
color={isDark ? colors.gray[300] : colors.gray[700]}
|
||||
/>
|
||||
<Text
|
||||
className={twMerge(
|
||||
'ml-2',
|
||||
vo.selected === option
|
||||
? isDark
|
||||
? 'text-white font-semibold'
|
||||
: 'text-indigo-800 font-semibold'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Button zum Hinzufügen einer neuen Variante */}
|
||||
<TouchableOpacity
|
||||
onPress={addVariantToDocument}
|
||||
className={twMerge(
|
||||
'flex-row items-center justify-center p-2 rounded-md mt-2 mb-4',
|
||||
isDark ? 'bg-gray-700' : 'bg-gray-200'
|
||||
)}
|
||||
>
|
||||
<Ionicons
|
||||
name="add-outline"
|
||||
size={20}
|
||||
color={isDark ? colors.gray[300] : colors.gray[700]}
|
||||
/>
|
||||
<Text className="ml-2">Variante zum Dokument hinzufügen</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Button zum Generieren aller Kombinationen */}
|
||||
{variantOptions.length > 1 && (
|
||||
<TouchableOpacity
|
||||
onPress={generateAllCombinations}
|
||||
className={twMerge(
|
||||
'flex-row items-center justify-center p-2 rounded-md mb-4',
|
||||
isDark ? 'bg-purple-700' : 'bg-purple-100'
|
||||
)}
|
||||
>
|
||||
<Ionicons
|
||||
name="git-network-outline"
|
||||
size={20}
|
||||
color={isDark ? colors.gray[300] : colors.gray[700]}
|
||||
/>
|
||||
<Text className={twMerge('ml-2', isDark ? 'text-white' : 'text-purple-800')}>
|
||||
Alle Kombinationen generieren ({calculateTotalCombinations()})
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Vorschau-Toggle */}
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPreview(!showPreview)}
|
||||
className={twMerge(
|
||||
'flex-row items-center p-2 rounded-md mb-4',
|
||||
isDark ? 'bg-gray-700' : 'bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<Ionicons
|
||||
name={showPreview ? 'eye-outline' : 'eye-off-outline'}
|
||||
size={20}
|
||||
color={isDark ? colors.gray[300] : colors.gray[700]}
|
||||
/>
|
||||
<Text className="ml-2">
|
||||
{showPreview ? 'Vorschau ausblenden' : 'Vorschau anzeigen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Vorschau */}
|
||||
{showPreview && (
|
||||
<View
|
||||
className={twMerge(
|
||||
'border p-4 rounded-md mb-4',
|
||||
isDark ? 'bg-gray-700 border-gray-600' : 'bg-gray-50 border-gray-300'
|
||||
)}
|
||||
>
|
||||
<Text className="text-lg font-semibold mb-2">Vorschau</Text>
|
||||
<ScrollView style={{ maxHeight: 200 }}>
|
||||
<Text>{previewContent}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
<View
|
||||
className={twMerge(
|
||||
'flex-row justify-end items-center p-4 border-t',
|
||||
isDark ? 'border-gray-700' : 'border-gray-200'
|
||||
)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className={twMerge(
|
||||
'flex-row items-center justify-center p-2 rounded-md mr-2',
|
||||
isDark ? 'bg-gray-700' : 'bg-gray-200'
|
||||
)}
|
||||
>
|
||||
<Text>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={createVariant}
|
||||
disabled={isCreatingVariant}
|
||||
className={twMerge(
|
||||
'flex-row items-center justify-center p-2 rounded-md',
|
||||
isDark ? 'bg-indigo-600' : 'bg-indigo-500',
|
||||
isCreatingVariant ? 'opacity-50' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{isCreatingVariant ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<Text className="text-white">Variante erstellen</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
});
|
||||
0
apps/context/apps/mobile/components/variants/index.ts
Normal file
0
apps/context/apps/mobile/components/variants/index.ts
Normal file
Loading…
Add table
Add a link
Reference in a new issue