import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, KeyboardAvoidingView, Modal, Platform, Pressable, RefreshControl, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { useLocalSearchParams } from 'expo-router'; import { createWikiPage, deleteWikiPage, fetchWikiPageById, fetchWikiTree, updateWikiPage, WikiTreeItem, } from '@/src/lib/api'; import { useAuth } from '@/src/providers/auth-provider'; const PRIMARY = '#69c350'; type FlatNode = WikiTreeItem & { depth: number }; type TiptapDoc = { type: string; content?: Record[]; }; type EditorMessage = { type: 'ready' | 'content' | 'error'; content?: unknown; message?: string; }; const WebViewComponent: any = (() => { try { // `react-native-webview` is optional until dependency install is complete on the user's machine. // eslint-disable-next-line @typescript-eslint/no-require-imports return require('react-native-webview').WebView; } catch { return null; } })(); function createEmptyDoc(): TiptapDoc { return { type: 'doc', content: [{ type: 'paragraph', content: [] }], }; } function normalizeDoc(value: unknown): TiptapDoc { if (value && typeof value === 'object' && (value as { type?: unknown }).type === 'doc') { return value as TiptapDoc; } if (typeof value === 'string' && value.trim()) { return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: value }] }], }; } return createEmptyDoc(); } function buildTiptapHtml(initialDoc: TiptapDoc): string { const initialDocJson = JSON.stringify(initialDoc).replace(/
`; } function isVirtualNode(node: WikiTreeItem): boolean { return Boolean(node.isVirtual) || String(node.id).startsWith('virtual-'); } export default function WikiScreen() { const { token } = useAuth(); const params = useLocalSearchParams<{ entityType?: string; entityId?: string; entityUuid?: string; title?: string }>(); const entityType = useMemo(() => String(params.entityType || '').trim(), [params.entityType]); const entityId = useMemo(() => { const value = Number(params.entityId); return Number.isFinite(value) && value > 0 ? value : null; }, [params.entityId]); const entityUuid = useMemo(() => String(params.entityUuid || '').trim() || null, [params.entityUuid]); const screenTitle = useMemo(() => String(params.title || '').trim() || 'Wiki', [params.title]); const [items, setItems] = useState([]); const [expandedIds, setExpandedIds] = useState([]); const [selectedPageId, setSelectedPageId] = useState(null); const [selectedTitle, setSelectedTitle] = useState(''); const [selectedDoc, setSelectedDoc] = useState(createEmptyDoc()); const [editorVersion, setEditorVersion] = useState(0); const [editorError, setEditorError] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [loadingPage, setLoadingPage] = useState(false); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [error, setError] = useState(null); const [info, setInfo] = useState(null); const [createModalOpen, setCreateModalOpen] = useState(false); const [creating, setCreating] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newIsFolder, setNewIsFolder] = useState(false); const webViewRef = useRef(null); const flatNodes = useMemo(() => { const byParent = new Map(); (items || []).forEach((item) => { const parentId = String(item.parentId || 'root'); if (!byParent.has(parentId)) byParent.set(parentId, []); byParent.get(parentId)?.push(item); }); const sortNodes = (nodes: WikiTreeItem[]) => nodes.sort((a, b) => { if ((a.isFolder ? 1 : 0) !== (b.isFolder ? 1 : 0)) return (b.isFolder ? 1 : 0) - (a.isFolder ? 1 : 0); const sortA = Number(a.sortOrder || 0); const sortB = Number(b.sortOrder || 0); if (sortA !== sortB) return sortA - sortB; return String(a.title || '').localeCompare(String(b.title || ''), 'de'); }); byParent.forEach((nodes, key) => { byParent.set(key, sortNodes(nodes)); }); const out: FlatNode[] = []; const walk = (parentId: string, depth: number) => { const children = byParent.get(parentId) || []; children.forEach((child) => { out.push({ ...child, depth }); const isExpandable = Boolean(child.isFolder) || (byParent.get(String(child.id)) || []).length > 0; if (isExpandable && expandedIds.includes(String(child.id))) { walk(String(child.id), depth + 1); } }); }; walk('root', 0); return out; }, [expandedIds, items]); const selectedNode = useMemo( () => (selectedPageId ? (items || []).find((item) => String(item.id) === String(selectedPageId)) || null : null), [items, selectedPageId] ); const selectedNodeIsVirtual = useMemo(() => (selectedNode ? isVirtualNode(selectedNode) : false), [selectedNode]); const isEntityScope = Boolean(entityType && (entityId || entityUuid)); const loadTree = useCallback( async (showSpinner = true) => { if (!token) return; if (showSpinner) setLoading(true); setError(null); setInfo(null); try { const rows = await fetchWikiTree(token, { entityType: entityType || undefined, entityId, entityUuid, }); setItems(rows || []); const autoExpand = new Set(); (rows || []).forEach((item) => { if (item.isFolder || isVirtualNode(item)) autoExpand.add(String(item.id)); }); setExpandedIds(Array.from(autoExpand)); } catch (err) { setError(err instanceof Error ? err.message : 'Wiki konnte nicht geladen werden.'); } finally { setLoading(false); setRefreshing(false); } }, [entityId, entityType, entityUuid, token] ); const loadPage = useCallback( async (pageId: string) => { if (!token) return; setLoadingPage(true); setError(null); setEditorError(null); try { const page = await fetchWikiPageById(token, pageId); setSelectedPageId(String(page.id)); setSelectedTitle(String(page.title || '')); setSelectedDoc(normalizeDoc(page.content)); setEditorVersion((prev) => prev + 1); } catch (err) { setError(err instanceof Error ? err.message : 'Wiki-Seite konnte nicht geladen werden.'); } finally { setLoadingPage(false); } }, [token] ); useEffect(() => { if (!token) return; void loadTree(true); }, [loadTree, token]); async function onRefresh() { setRefreshing(true); await loadTree(false); } async function onSavePage() { if (!token || !selectedPageId || saving) return; setSaving(true); setError(null); setInfo(null); try { await updateWikiPage(token, selectedPageId, { title: selectedTitle.trim() || 'Ohne Titel', content: selectedDoc, }); setItems((prev) => prev.map((item) => String(item.id) === String(selectedPageId) ? { ...item, title: selectedTitle.trim() || 'Ohne Titel' } : item ) ); setInfo('Wiki-Seite gespeichert.'); } catch (err) { setError(err instanceof Error ? err.message : 'Wiki-Seite konnte nicht gespeichert werden.'); } finally { setSaving(false); } } async function onDeletePage() { if (!token || !selectedPageId || deleting) return; setDeleting(true); setError(null); setInfo(null); try { await deleteWikiPage(token, selectedPageId); setSelectedPageId(null); setSelectedTitle(''); setSelectedDoc(createEmptyDoc()); setEditorVersion((prev) => prev + 1); await loadTree(false); setInfo('Wiki-Seite gelöscht.'); } catch (err) { setError(err instanceof Error ? err.message : 'Wiki-Seite konnte nicht gelöscht werden.'); } finally { setDeleting(false); } } function closeCreateModal() { setCreateModalOpen(false); setNewTitle(''); setNewIsFolder(false); } async function onCreatePage() { if (!token || creating) return; const title = newTitle.trim(); if (!title) { setError('Bitte einen Titel eingeben.'); return; } setCreating(true); setError(null); setInfo(null); try { const page = await createWikiPage(token, { title, parentId: null, isFolder: newIsFolder, entityType: entityType || undefined, entityId, entityUuid, }); closeCreateModal(); await loadTree(false); if (!newIsFolder && page?.id) { await loadPage(String(page.id)); } setInfo(newIsFolder ? 'Wiki-Ordner erstellt.' : 'Wiki-Seite erstellt.'); } catch (err) { setError(err instanceof Error ? err.message : 'Wiki-Seite konnte nicht erstellt werden.'); } finally { setCreating(false); } } return ( <> }> {screenTitle} {isEntityScope ? 'Entity-Wiki' : 'Zentrale Wissensübersicht'} setCreateModalOpen(true)}> Neue Seite {error ? {error} : null} {info ? {info} : null} Seiten {loading ? ( Wiki wird geladen... ) : flatNodes.length === 0 ? ( Noch keine Wiki-Seiten vorhanden. ) : ( {flatNodes.map((node) => { const id = String(node.id); const isSelected = selectedPageId === id; const childrenExist = flatNodes.some((item) => String(item.parentId || '') === id); const expandable = Boolean(node.isFolder) || childrenExist || isVirtualNode(node); const expanded = expandedIds.includes(id); return ( { if (expandable) { setExpandedIds((prev) => (prev.includes(id) ? prev.filter((entry) => entry !== id) : [...prev, id])); } if (node.isFolder || isVirtualNode(node)) return; void loadPage(id); }}> {expandable ? (expanded ? '⌄' : '›') : '·'} {node.isFolder || isVirtualNode(node) ? '📁' : '📄'} {String(node.title || 'Ohne Titel')} ); })} )} Inhalt {!selectedPageId ? ( Wähle eine Wiki-Seite aus der Liste aus. ) : loadingPage ? ( Inhalt wird geladen... ) : ( <> {WebViewComponent ? ( { try { const payload = JSON.parse(String(event?.nativeEvent?.data || '{}')) as EditorMessage; if (payload.type === 'content' && payload.content) { setSelectedDoc(normalizeDoc(payload.content)); } else if (payload.type === 'error') { setEditorError(payload.message || 'Editor-Fehler'); } } catch { setEditorError('Editor-Nachricht konnte nicht gelesen werden.'); } }} onError={() => setEditorError('TipTap Editor konnte nicht geladen werden.')} setSupportMultipleWindows={false} style={styles.webView} /> ) : ( `react-native-webview` ist noch nicht installiert. )} {editorError ? {editorError} : null} void onSavePage()} disabled={saving}> {saving ? 'Speichert...' : 'Speichern'} void onDeletePage()} disabled={deleting || Boolean(selectedNode?.isFolder) || selectedNodeIsVirtual}> {deleting ? 'Löscht...' : 'Löschen'} )} Neue Wiki-Seite {!isEntityScope ? ( setNewIsFolder((prev) => !prev)}> Als Ordner erstellen ) : null} Abbrechen void onCreatePage()} disabled={creating}> {creating ? 'Erstelle...' : 'Erstellen'} ); } const styles = StyleSheet.create({ container: { padding: 16, gap: 12, backgroundColor: '#f9fafb', }, headerCard: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', padding: 12, gap: 8, }, headerTitle: { color: '#111827', fontSize: 18, fontWeight: '700', }, headerSubtitle: { color: '#6b7280', fontSize: 13, }, card: { backgroundColor: '#ffffff', borderRadius: 12, borderWidth: 1, borderColor: '#e5e7eb', padding: 12, gap: 8, }, sectionTitle: { color: '#111827', fontSize: 16, fontWeight: '700', }, treeWrap: { borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 10, overflow: 'hidden', }, treeRow: { borderBottomWidth: 1, borderBottomColor: '#e5e7eb', backgroundColor: '#ffffff', }, treeRowActive: { backgroundColor: '#f0f9eb', }, treeRowInner: { minHeight: 40, flexDirection: 'row', alignItems: 'center', gap: 6, paddingRight: 10, }, treeArrow: { color: '#6b7280', fontSize: 15, width: 14, textAlign: 'center', }, treeIcon: { fontSize: 14, }, treeTitle: { flex: 1, color: '#111827', fontSize: 14, fontWeight: '500', }, input: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, color: '#111827', backgroundColor: '#ffffff', }, editorWrap: { height: 320, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 10, overflow: 'hidden', backgroundColor: '#ffffff', }, webView: { flex: 1, backgroundColor: '#ffffff', }, missingWebView: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 12, }, editorActions: { flexDirection: 'row', gap: 8, }, primaryButton: { minHeight: 38, borderRadius: 9, backgroundColor: PRIMARY, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center', }, primaryButtonText: { color: '#ffffff', fontWeight: '700', fontSize: 13, }, secondaryButton: { minHeight: 38, borderRadius: 9, borderWidth: 1, borderColor: '#d1d5db', backgroundColor: '#ffffff', paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center', }, secondaryButtonText: { color: '#374151', fontWeight: '600', fontSize: 13, }, deleteButton: { minHeight: 38, borderRadius: 9, borderWidth: 1, borderColor: '#ef4444', backgroundColor: '#ffffff', paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center', }, deleteButtonText: { color: '#b91c1c', fontWeight: '700', fontSize: 13, }, buttonDisabled: { opacity: 0.6, }, empty: { color: '#6b7280', fontSize: 13, paddingVertical: 4, }, loadingBox: { paddingVertical: 16, alignItems: 'center', gap: 8, }, loadingText: { color: '#6b7280', }, error: { color: '#dc2626', fontSize: 13, }, info: { color: '#166534', fontSize: 13, }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', justifyContent: 'center', padding: 16, }, modalKeyboardWrap: { width: '100%', }, modalCard: { backgroundColor: '#ffffff', borderRadius: 14, padding: 14, gap: 10, }, modalTitle: { color: '#111827', fontSize: 16, fontWeight: '700', }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, }, filterButton: { alignSelf: 'flex-start', borderWidth: 1, borderColor: '#d1d5db', borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6, backgroundColor: '#ffffff', }, filterButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea', }, filterButtonText: { color: '#374151', fontSize: 12, fontWeight: '500', }, filterButtonTextActive: { color: '#3d7a30', }, });