Files
FEDEO/mobile/app/more/wiki.tsx
2026-03-16 20:46:26 +01:00

805 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>[];
};
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(/</g, '\\u003c');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<style>
html, body { margin: 0; padding: 0; background: #ffffff; color: #111827; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
#app { display: flex; flex-direction: column; height: 100vh; }
#toolbar { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px; border-bottom: 1px solid #e5e7eb; background: #f9fafb; }
button { border: 1px solid #d1d5db; background: #fff; color: #374151; border-radius: 8px; padding: 6px 10px; font-size: 12px; }
button:active { transform: translateY(1px); }
#editor { flex: 1; overflow: auto; padding: 12px; font-size: 16px; line-height: 1.5; }
.ProseMirror { min-height: 100%; outline: none; }
.ProseMirror p { margin: 0 0 10px; }
.ProseMirror h1 { font-size: 1.5rem; margin: 0.2em 0 0.5em; }
.ProseMirror h2 { font-size: 1.25rem; margin: 0.2em 0 0.5em; }
.ProseMirror ul, .ProseMirror ol { padding-left: 1.2rem; }
.ProseMirror a { color: #2563eb; text-decoration: underline; }
</style>
</head>
<body>
<div id="app">
<div id="toolbar">
<button id="bold" type="button"><b>B</b></button>
<button id="italic" type="button"><i>I</i></button>
<button id="h1" type="button">H1</button>
<button id="h2" type="button">H2</button>
<button id="bullet" type="button">Liste</button>
<button id="ordered" type="button">Nummeriert</button>
<button id="undo" type="button">↶</button>
<button id="redo" type="button">↷</button>
</div>
<div id="editor"></div>
</div>
<script type="module">
import { Editor } from 'https://esm.sh/@tiptap/core@2.10.0';
import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2.10.0';
import Link from 'https://esm.sh/@tiptap/extension-link@2.10.0';
const initialContent = ${initialDocJson};
const send = (payload) => {
if (window.ReactNativeWebView && typeof window.ReactNativeWebView.postMessage === 'function') {
window.ReactNativeWebView.postMessage(JSON.stringify(payload));
}
};
try {
const editor = new Editor({
element: document.querySelector('#editor'),
extensions: [
StarterKit,
Link.configure({ openOnClick: false, autolink: true, linkOnPaste: true }),
],
content: initialContent,
autofocus: false,
editorProps: {
attributes: {
spellcheck: 'true',
},
},
onUpdate: ({ editor }) => {
send({ type: 'content', content: editor.getJSON() });
},
});
const click = (id, handler) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('click', () => {
handler();
editor.commands.focus();
});
};
click('bold', () => editor.chain().focus().toggleBold().run());
click('italic', () => editor.chain().focus().toggleItalic().run());
click('h1', () => editor.chain().focus().toggleHeading({ level: 1 }).run());
click('h2', () => editor.chain().focus().toggleHeading({ level: 2 }).run());
click('bullet', () => editor.chain().focus().toggleBulletList().run());
click('ordered', () => editor.chain().focus().toggleOrderedList().run());
click('undo', () => editor.chain().focus().undo().run());
click('redo', () => editor.chain().focus().redo().run());
send({ type: 'ready' });
send({ type: 'content', content: editor.getJSON() });
} catch (error) {
send({ type: 'error', message: String(error?.message || error) });
}
</script>
</body>
</html>`;
}
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<WikiTreeItem[]>([]);
const [expandedIds, setExpandedIds] = useState<string[]>([]);
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [selectedTitle, setSelectedTitle] = useState('');
const [selectedDoc, setSelectedDoc] = useState<TiptapDoc>(createEmptyDoc());
const [editorVersion, setEditorVersion] = useState(0);
const [editorError, setEditorError] = useState<string | null>(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<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newIsFolder, setNewIsFolder] = useState(false);
const webViewRef = useRef<any>(null);
const flatNodes = useMemo(() => {
const byParent = new Map<string, WikiTreeItem[]>();
(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<string>();
(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 (
<>
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.headerCard}>
<Text style={styles.headerTitle}>{screenTitle}</Text>
<Text style={styles.headerSubtitle}>{isEntityScope ? 'Entity-Wiki' : 'Zentrale Wissensübersicht'}</Text>
<Pressable style={styles.primaryButton} onPress={() => setCreateModalOpen(true)}>
<Text style={styles.primaryButtonText}>Neue Seite</Text>
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
{info ? <Text style={styles.info}>{info}</Text> : null}
<View style={styles.card}>
<Text style={styles.sectionTitle}>Seiten</Text>
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Wiki wird geladen...</Text>
</View>
) : flatNodes.length === 0 ? (
<Text style={styles.empty}>Noch keine Wiki-Seiten vorhanden.</Text>
) : (
<View style={styles.treeWrap}>
{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 (
<Pressable
key={id}
style={[styles.treeRow, isSelected ? styles.treeRowActive : null]}
onPress={() => {
if (expandable) {
setExpandedIds((prev) => (prev.includes(id) ? prev.filter((entry) => entry !== id) : [...prev, id]));
}
if (node.isFolder || isVirtualNode(node)) return;
void loadPage(id);
}}>
<View style={[styles.treeRowInner, { paddingLeft: 10 + node.depth * 16 }]}>
<Text style={styles.treeArrow}>{expandable ? (expanded ? '⌄' : '') : '·'}</Text>
<Text style={styles.treeIcon}>{node.isFolder || isVirtualNode(node) ? '📁' : '📄'}</Text>
<Text style={styles.treeTitle} numberOfLines={1}>
{String(node.title || 'Ohne Titel')}
</Text>
</View>
</Pressable>
);
})}
</View>
)}
</View>
<View style={styles.card}>
<Text style={styles.sectionTitle}>Inhalt</Text>
{!selectedPageId ? (
<Text style={styles.empty}>Wähle eine Wiki-Seite aus der Liste aus.</Text>
) : loadingPage ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Inhalt wird geladen...</Text>
</View>
) : (
<>
<TextInput
value={selectedTitle}
onChangeText={setSelectedTitle}
placeholder="Titel"
placeholderTextColor="#9ca3af"
style={styles.input}
/>
<View style={styles.editorWrap}>
{WebViewComponent ? (
<WebViewComponent
ref={webViewRef}
key={`${selectedPageId}-${editorVersion}`}
originWhitelist={['*']}
source={{ html: buildTiptapHtml(selectedDoc) }}
javaScriptEnabled
domStorageEnabled
onMessage={(event: any) => {
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}
/>
) : (
<View style={styles.missingWebView}>
<Text style={styles.error}>`react-native-webview` ist noch nicht installiert.</Text>
</View>
)}
</View>
{editorError ? <Text style={styles.error}>{editorError}</Text> : null}
<View style={styles.editorActions}>
<Pressable
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
onPress={() => void onSavePage()}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichert...' : 'Speichern'}</Text>
</Pressable>
<Pressable
style={[styles.deleteButton, deleting ? styles.buttonDisabled : null]}
onPress={() => void onDeletePage()}
disabled={deleting || Boolean(selectedNode?.isFolder) || selectedNodeIsVirtual}>
<Text style={styles.deleteButtonText}>{deleting ? 'Löscht...' : 'Löschen'}</Text>
</Pressable>
</View>
</>
)}
</View>
</ScrollView>
<Modal visible={createModalOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neue Wiki-Seite</Text>
<TextInput
value={newTitle}
onChangeText={setNewTitle}
placeholder="Titel"
placeholderTextColor="#9ca3af"
style={styles.input}
/>
{!isEntityScope ? (
<Pressable
style={[styles.filterButton, newIsFolder ? styles.filterButtonActive : null]}
onPress={() => setNewIsFolder((prev) => !prev)}>
<Text style={[styles.filterButtonText, newIsFolder ? styles.filterButtonTextActive : null]}>
Als Ordner erstellen
</Text>
</Pressable>
) : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, creating ? styles.buttonDisabled : null]}
onPress={() => void onCreatePage()}
disabled={creating}>
<Text style={styles.primaryButtonText}>{creating ? 'Erstelle...' : 'Erstellen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</>
);
}
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',
},
});