managarten/apps/context/apps/mobile/components/documents/DocumentGallery.tsx
Till-JS bb0e0cf5cb 🚚 feat(context): integrate context app into monorepo
Restructure the context app (formerly basetext) to follow the monorepo
pattern with proper workspace configuration.

Changes:
- Move app files to apps/context/apps/mobile/
- Rename package to @context/mobile
- Update bundle ID to com.manacore.context
- Create pnpm-workspace.yaml for project workspace
- Add dev scripts to root package.json
- Update CLAUDE.md with project documentation

The app structure is prepared for future web/backend additions.

Note: Existing TypeScript errors in the original codebase are preserved.
These should be fixed in a follow-up PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 15:09:04 +01:00

672 lines
17 KiB
TypeScript

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