mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 17:59:39 +02:00
- Restructure chat as apps/mobile, apps/web, apps/landing, backend - Add NestJS backend for secure Azure OpenAI API calls - Remove exposed API key from mobile app (security fix) - Add shared chat-types package - Create SvelteKit web app scaffold - Create Astro landing page scaffold - Update pnpm workspace configuration - Add project-level CLAUDE.md documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
465 lines
No EOL
14 KiB
TypeScript
465 lines
No EOL
14 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
useWindowDimensions,
|
|
Platform
|
|
} from 'react-native';
|
|
import { useTheme } from '@react-navigation/native';
|
|
import { useRouter } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { Document } from '../services/document';
|
|
import { supabase } from '../utils/supabase';
|
|
import Markdown from 'react-native-markdown-display';
|
|
|
|
type DocumentWithTitle = Document & {
|
|
conversation_title: string;
|
|
};
|
|
|
|
export default function DocumentsScreen() {
|
|
const { colors } = useTheme();
|
|
const router = useRouter();
|
|
const { width } = useWindowDimensions();
|
|
const [documents, setDocuments] = useState<DocumentWithTitle[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [userId, setUserId] = useState<string | null>(null);
|
|
|
|
// Berechne die Anzahl der Spalten basierend auf der Bildschirmbreite
|
|
const columnsCount = useMemo(() => {
|
|
// Mobile (schmaler Bildschirm)
|
|
if (width < 600) {
|
|
return 1;
|
|
}
|
|
// Tablet
|
|
if (width < 1100) {
|
|
return 2;
|
|
}
|
|
// Desktop oder großes Tablet
|
|
return 3;
|
|
}, [width]);
|
|
|
|
// Berechne die Breite jeder Karte basierend auf der Spaltenanzahl
|
|
const cardWidth = useMemo(() => {
|
|
const padding = 16; // Container-Padding rechts und links
|
|
const gap = 16; // Abstand zwischen Karten
|
|
const contentWidth = width - (padding * 2);
|
|
const gapTotal = gap * (columnsCount - 1);
|
|
const availableWidth = contentWidth - gapTotal;
|
|
|
|
// Verhältnis für schmalere Karten, je nach Spaltenanzahl anpassen
|
|
const widthRatio = columnsCount === 1 ? 0.95 : // Fast volle Breite bei 1 Spalte
|
|
columnsCount === 2 ? 0.48 : // Etwas schmaler bei 2 Spalten
|
|
0.31; // Noch schmaler bei 3 Spalten
|
|
|
|
return (availableWidth * widthRatio);
|
|
}, [width, columnsCount]);
|
|
|
|
useEffect(() => {
|
|
const checkUser = async () => {
|
|
const { data } = await supabase.auth.getUser();
|
|
if (data?.user) {
|
|
setUserId(data.user.id);
|
|
} else {
|
|
// In einer echten App würden wir hier zur Login-Seite weiterleiten
|
|
// Für jetzt verwenden wir eine Test-ID
|
|
setUserId('test-user-id');
|
|
}
|
|
};
|
|
checkUser();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (userId) {
|
|
loadDocuments();
|
|
}
|
|
}, [userId]);
|
|
|
|
const loadDocuments = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
|
|
// Lade alle Konversationen des Benutzers, die im Dokumentmodus sind
|
|
const { data: conversations, error: convError } = await supabase
|
|
.from('conversations')
|
|
.select('id, title, document_mode')
|
|
.eq('user_id', userId)
|
|
.eq('document_mode', true);
|
|
|
|
if (convError) {
|
|
console.error('Fehler beim Laden der Konversationen:', convError);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!conversations || conversations.length === 0) {
|
|
setDocuments([]);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Für jede Konversation den neuesten Dokumentstand laden
|
|
const latestDocuments: DocumentWithTitle[] = [];
|
|
|
|
for (const conv of conversations) {
|
|
const { data: docData, error: docError } = await supabase
|
|
.from('documents')
|
|
.select('*')
|
|
.eq('conversation_id', conv.id)
|
|
.order('version', { ascending: false })
|
|
.limit(1)
|
|
.single();
|
|
|
|
if (docError) {
|
|
if (docError.code !== 'PGRST116') { // Ignore "No rows found" error
|
|
console.error(`Fehler beim Laden des Dokuments für Konversation ${conv.id}:`, docError);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (docData) {
|
|
latestDocuments.push({
|
|
...docData,
|
|
conversation_title: conv.title || 'Unbenannte Konversation'
|
|
});
|
|
}
|
|
}
|
|
|
|
setDocuments(latestDocuments);
|
|
} catch (error) {
|
|
console.error('Fehler beim Laden der Dokumente:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const navigateToConversation = (conversationId: string) => {
|
|
router.push(`/conversation/${conversationId}`);
|
|
};
|
|
|
|
// Funktion zum Extrahieren eines Titels aus dem Dokumentinhalt
|
|
const extractDocumentTitle = (content: string): string => {
|
|
// Suche nach einer Markdown-Überschrift Ebene 1 am Anfang
|
|
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
if (titleMatch && titleMatch[1]) {
|
|
return titleMatch[1].trim();
|
|
}
|
|
|
|
// Alternativ: Suche nach einer Markdown-Überschrift Ebene 2
|
|
const subtitleMatch = content.match(/^##\s+(.+)$/m);
|
|
if (subtitleMatch && subtitleMatch[1]) {
|
|
return subtitleMatch[1].trim();
|
|
}
|
|
|
|
// Wenn keine Überschrift gefunden wurde, nimm die ersten Wörter
|
|
const firstLine = content.split('\n')[0].trim();
|
|
if (firstLine.length > 0) {
|
|
return firstLine.length > 40 ? `${firstLine.substring(0, 37)}...` : firstLine;
|
|
}
|
|
|
|
return 'Dokument ohne Titel';
|
|
};
|
|
|
|
// Funktion zum Entfernen nur der ersten H1-Überschrift aus dem Inhalt
|
|
const removeHeadingFromContent = (content: string, title: string): string => {
|
|
// Prüfe, ob das Dokument mit einer H1-Überschrift beginnt
|
|
const firstLineMatch = content.match(/^#\s+(.+)$/m);
|
|
|
|
if (firstLineMatch && firstLineMatch.index === 0) {
|
|
// Entferne nur die erste H1-Überschrift am Anfang des Dokuments
|
|
const parts = content.split('\n');
|
|
parts.shift(); // Entferne die erste Zeile (H1-Überschrift)
|
|
|
|
// Entferne leere Zeilen am Anfang
|
|
let modifiedContent = parts.join('\n').replace(/^\s+/, '');
|
|
|
|
return modifiedContent;
|
|
}
|
|
|
|
// Wenn keine H1-Überschrift am Anfang gefunden wurde,
|
|
// gib den ursprünglichen Inhalt zurück
|
|
return content;
|
|
};
|
|
|
|
// Funktion zum Formatieren des Datums
|
|
const formatDate = (dateString: string): string => {
|
|
const date = new Date(dateString);
|
|
const day = date.getDate().toString().padStart(2, '0');
|
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
const year = date.getFullYear();
|
|
return `${day}.${month}.${year}`;
|
|
};
|
|
|
|
return (
|
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
|
<View style={styles.header}>
|
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
|
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
|
</TouchableOpacity>
|
|
<Text style={[styles.headerTitle, { color: colors.text }]}>Alle Dokumente</Text>
|
|
<TouchableOpacity style={styles.refreshButton} onPress={loadDocuments}>
|
|
<Ionicons name="refresh" size={24} color={colors.text} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{isLoading ? (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
|
<Text style={[styles.loadingText, { color: colors.text }]}>
|
|
Dokumente werden geladen...
|
|
</Text>
|
|
</View>
|
|
) : documents.length === 0 ? (
|
|
<View style={styles.emptyContainer}>
|
|
<Ionicons name="document-text-outline" size={64} color={colors.text} style={styles.emptyIcon} />
|
|
<Text style={[styles.emptyText, { color: colors.text }]}>
|
|
Keine Dokumente gefunden
|
|
</Text>
|
|
<Text style={[styles.emptySubtext, { color: colors.text }]}>
|
|
Erstelle ein neues Dokument in einer Konversation mit aktiviertem Dokumentmodus.
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<ScrollView style={styles.scrollContainer} contentContainerStyle={styles.documentsContainer}>
|
|
{documents.map((doc) => (
|
|
<TouchableOpacity
|
|
key={doc.id}
|
|
style={[
|
|
styles.documentCard,
|
|
{
|
|
backgroundColor: colors.card,
|
|
borderColor: colors.border,
|
|
width: cardWidth,
|
|
// Keine quadratischen Karten mehr, stattdessen festgelegte Höhen
|
|
height: 280,
|
|
minHeight: 220,
|
|
maxHeight: 320
|
|
}
|
|
]}
|
|
onPress={() => navigateToConversation(doc.conversation_id)}
|
|
>
|
|
<View style={styles.documentHeader}>
|
|
<Text style={[styles.documentTitle, { color: colors.text }]}>
|
|
{extractDocumentTitle(doc.content)}
|
|
</Text>
|
|
<View style={styles.documentMeta}>
|
|
<Text style={[styles.conversationTitle, { color: colors.text }]}>
|
|
{doc.conversation_title}
|
|
</Text>
|
|
<View style={styles.metaRight}>
|
|
<Text style={[styles.documentDate, { color: colors.text }]}>
|
|
{formatDate(doc.updated_at)}
|
|
</Text>
|
|
<Text style={[styles.documentVersion, { color: colors.text }]}>
|
|
v{doc.version}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
<View style={styles.contentContainer}>
|
|
<ScrollView style={styles.documentContent} nestedScrollEnabled={true}>
|
|
<Markdown
|
|
style={{
|
|
body: {
|
|
color: colors.text,
|
|
fontSize: 13,
|
|
lineHeight: 18
|
|
},
|
|
// Normale Anzeige für H1-Überschriften im Inhalt
|
|
heading1: {
|
|
color: colors.text,
|
|
fontSize: 16,
|
|
fontWeight: 'bold',
|
|
marginTop: 8,
|
|
marginBottom: 6,
|
|
lineHeight: 20,
|
|
paddingBottom: 4,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(0,0,0,0.1)',
|
|
},
|
|
heading2: {
|
|
color: colors.text,
|
|
fontSize: 14,
|
|
fontWeight: 'bold',
|
|
marginVertical: 5,
|
|
lineHeight: 18
|
|
},
|
|
paragraph: {
|
|
color: colors.text,
|
|
marginBottom: 8,
|
|
fontSize: 13,
|
|
lineHeight: 18
|
|
},
|
|
blockquote: {
|
|
backgroundColor: colors.card,
|
|
borderLeftColor: colors.primary,
|
|
borderLeftWidth: 2,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 4,
|
|
marginVertical: 6
|
|
},
|
|
code_block: {
|
|
backgroundColor: colors.card,
|
|
padding: 6,
|
|
borderRadius: 3,
|
|
fontSize: 12,
|
|
lineHeight: 16
|
|
},
|
|
link: { color: colors.primary }
|
|
}}
|
|
>
|
|
{removeHeadingFromContent(doc.content, extractDocumentTitle(doc.content))}
|
|
</Markdown>
|
|
</ScrollView>
|
|
</View>
|
|
</TouchableOpacity>
|
|
))}
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
padding: 16,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(0,0,0,0.1)',
|
|
},
|
|
backButton: {
|
|
padding: 6,
|
|
},
|
|
headerTitle: {
|
|
fontSize: 20,
|
|
fontWeight: 'bold',
|
|
flex: 1,
|
|
paddingLeft: 12,
|
|
},
|
|
refreshButton: {
|
|
padding: 6,
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
loadingText: {
|
|
marginTop: 12,
|
|
fontSize: 16,
|
|
},
|
|
emptyContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: 20,
|
|
},
|
|
emptyIcon: {
|
|
marginBottom: 20,
|
|
opacity: 0.6,
|
|
},
|
|
emptyText: {
|
|
fontSize: 18,
|
|
fontWeight: 'bold',
|
|
marginBottom: 8,
|
|
},
|
|
emptySubtext: {
|
|
fontSize: 14,
|
|
textAlign: 'center',
|
|
opacity: 0.7,
|
|
maxWidth: '80%',
|
|
},
|
|
scrollContainer: {
|
|
flex: 1,
|
|
},
|
|
documentsContainer: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
padding: 16,
|
|
// In einem flexiblen Layout nicht mehr space-between verwenden
|
|
// sondern einen festen Abstand zwischen Items
|
|
gap: 20,
|
|
// Alignment um die Karten horizontal zu zentrieren
|
|
justifyContent: 'center'
|
|
},
|
|
documentCard: {
|
|
// width wird dynamisch basierend auf columnsCount berechnet
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
overflow: 'hidden',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
// Shadow für die Karten hinzufügen
|
|
...Platform.select({
|
|
ios: {
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 4,
|
|
},
|
|
android: {
|
|
elevation: 3,
|
|
},
|
|
web: {
|
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
|
},
|
|
}),
|
|
},
|
|
documentHeader: {
|
|
padding: 16,
|
|
paddingBottom: 12,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(0,0,0,0.1)',
|
|
},
|
|
documentTitle: {
|
|
fontSize: 16,
|
|
fontWeight: 'bold',
|
|
marginBottom: 8,
|
|
lineHeight: 22,
|
|
},
|
|
documentMeta: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingRight: 8,
|
|
},
|
|
conversationTitle: {
|
|
fontSize: 12,
|
|
opacity: 0.7,
|
|
flex: 1,
|
|
},
|
|
metaRight: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
documentDate: {
|
|
fontSize: 11,
|
|
opacity: 0.7,
|
|
},
|
|
documentVersion: {
|
|
fontSize: 12,
|
|
fontWeight: 'bold',
|
|
backgroundColor: 'rgba(0,0,0,0.1)',
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 2,
|
|
borderRadius: 10,
|
|
},
|
|
contentContainer: {
|
|
flex: 1,
|
|
// Vorschau-Bereich kleiner machen
|
|
maxHeight: 180,
|
|
},
|
|
documentContent: {
|
|
padding: 12,
|
|
// Zusätzliche Eigenschaften für einen besseren Vorschaubereich
|
|
paddingTop: 8,
|
|
},
|
|
}); |