Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View 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',
};

View file

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

View file

@ -0,0 +1,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`,
};

View 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`,
};

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

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

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

View 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',
},
});

View 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',
},
});

File diff suppressed because it is too large Load diff

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

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

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

View file

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

View file

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

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

View file

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

View file

@ -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',
},
});

View file

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

View 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;

View file

@ -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,
};
}}
/>
);
};

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

View file

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

View file

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

View file

@ -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',
},
});

View file

@ -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',
},
});

View file

@ -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',
},
});

View file

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

View file

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

View file

@ -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',
},
});

View file

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

View file

@ -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',
},
});

View file

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

View file

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

View file

@ -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',
},
});

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

View file

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

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

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

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

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

View file

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

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

View file

@ -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',
},
});

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

View file

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

View file

@ -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;

View file

@ -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;

View 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;

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

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

View file

@ -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,
}}
/>
);
};

View 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',
},
});

View file

@ -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',
},
});

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

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

View file

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

View 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',
},
});

View 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',
},
});

View file

@ -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,
}}
/>
);
};

View file

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

View 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',
},
});

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

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

View file

@ -0,0 +1,2 @@
export * from './ThemeProvider';
export * from './ThemeSelector';

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

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

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

View 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',
},
});

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

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

View 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%',
},
});

View 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',
},
});

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

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

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

View 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',
},
});

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

View 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%',
},
});