Files
dashboard/frontend/src/pages/Wissen.tsx
2026-03-12 10:21:26 +01:00

281 lines
10 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import {
Box,
TextField,
Typography,
Paper,
List,
ListItem,
ListItemButton,
ListItemText,
CircularProgress,
InputAdornment,
Divider,
Chip,
IconButton,
Tooltip,
} from '@mui/material';
import { Search as SearchIcon, OpenInNew } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import DOMPurify from 'dompurify';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { bookstackApi } from '../services/bookstack';
import { safeOpenUrl } from '../utils/safeOpenUrl';
import type { BookStackPage, BookStackSearchResult } from '../types/bookstack.types';
export default function Wissen() {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedPageId, setSelectedPageId] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedSearch(searchTerm.trim());
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchTerm]);
const recentQuery = useQuery({
queryKey: ['bookstack', 'recent'],
queryFn: () => bookstackApi.getRecent(),
});
const searchQuery = useQuery({
queryKey: ['bookstack', 'search', debouncedSearch],
queryFn: () => bookstackApi.search(debouncedSearch),
enabled: debouncedSearch.length > 0,
});
const pageQuery = useQuery({
queryKey: ['bookstack', 'page', selectedPageId],
queryFn: () => bookstackApi.getPage(selectedPageId!),
enabled: selectedPageId !== null,
});
const handleSelectPage = useCallback((id: number) => {
setSelectedPageId(id);
}, []);
const isNotConfigured =
recentQuery.data && !recentQuery.data.configured;
if (isNotConfigured) {
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>
Wissen
</Typography>
<Typography color="text.secondary">
BookStack ist nicht konfiguriert. Bitte BOOKSTACK_URL, BOOKSTACK_TOKEN_ID und
BOOKSTACK_TOKEN_SECRET in der .env-Datei setzen.
</Typography>
</Box>
</DashboardLayout>
);
}
const isSearching = debouncedSearch.length > 0;
const listItems: (BookStackSearchResult | BookStackPage)[] = isSearching
? searchQuery.data?.data ?? []
: recentQuery.data?.data ?? [];
const listLoading = isSearching ? searchQuery.isLoading : recentQuery.isLoading;
return (
<DashboardLayout>
<Box sx={{ display: 'flex', height: 'calc(100vh - 120px)', gap: 2, p: 2 }}>
{/* Left panel: search + list */}
<Paper sx={{ width: '40%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ p: 2 }}>
<TextField
fullWidth
size="small"
placeholder="Seiten durchsuchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</Box>
<Divider />
<Box sx={{ px: 2, pt: 1.5, pb: 0.5, display: 'flex', alignItems: 'center', gap: 1 }}>
{isSearching ? (
<>
<Typography variant="subtitle2" color="text.secondary">
Suchergebnisse für &quot;{debouncedSearch}&quot;
</Typography>
<Chip label={listItems.length} size="small" />
</>
) : (
<Typography variant="subtitle2" color="text.secondary">
Zuletzt geänderte Seiten
</Typography>
)}
</Box>
<Divider />
<Box sx={{ flex: 1, overflow: 'auto' }}>
{listLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : listItems.length === 0 ? (
<Typography sx={{ p: 2 }} color="text.secondary">
{isSearching ? 'Keine Ergebnisse gefunden.' : 'Keine Seiten vorhanden.'}
</Typography>
) : (
<List disablePadding>
{listItems.map((item) => {
const isRecentPage = 'updated_at' in item && !isSearching;
const bookName = 'book' in item && item.book ? item.book.name : undefined;
const secondaryParts: string[] = [];
if (bookName) secondaryParts.push(bookName);
if (isRecentPage && (item as BookStackPage).updated_at) {
secondaryParts.push(
`Geändert ${formatDistanceToNow(new Date((item as BookStackPage).updated_at), { addSuffix: true, locale: de })}`
);
}
return (
<ListItem key={item.id} disablePadding>
<ListItemButton
selected={selectedPageId === item.id}
onClick={() => handleSelectPage(item.id)}
>
<ListItemText
primary={item.name}
secondary={secondaryParts.length > 0 ? secondaryParts.join(' · ') : undefined}
primaryTypographyProps={{ noWrap: true }}
secondaryTypographyProps={{ noWrap: true }}
/>
</ListItemButton>
</ListItem>
);
})}
</List>
)}
</Box>
</Paper>
{/* Right panel: page content */}
<Paper sx={{ width: '60%', overflow: 'auto', p: 3 }}>
{!selectedPageId ? (
<Typography color="text.secondary">
Seite aus der Liste auswaehlen, um den Inhalt anzuzeigen.
</Typography>
) : pageQuery.isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : pageQuery.isError ? (
<Typography color="error">
Fehler beim Laden der Seite.
</Typography>
) : pageQuery.data?.data ? (
<Box>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="h5" gutterBottom sx={{ flex: 1 }}>
{pageQuery.data.data.name}
</Typography>
{pageQuery.data.data.url && (
<Tooltip title="In BookStack öffnen">
<IconButton
size="small"
onClick={() => safeOpenUrl(pageQuery.data!.data!.url)}
>
<OpenInNew fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
{pageQuery.data.data.book && (
<Typography variant="body2" color="text.secondary" gutterBottom>
Buch: {pageQuery.data.data.book.name}
</Typography>
)}
<Divider sx={{ my: 2 }} />
<Box
className="bookstack-content"
sx={(theme) => ({
'& h1, & h2, & h3, & h4, & h5, & h6': {
color: theme.palette.text.primary,
mt: 2,
mb: 1,
},
'& p': {
color: theme.palette.text.primary,
lineHeight: 1.7,
mb: 1,
},
'& a': {
color: theme.palette.primary.main,
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
},
'& table': {
borderCollapse: 'collapse',
width: '100%',
mb: 2,
},
'& th, & td': {
border: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(1),
textAlign: 'left',
},
'& th': {
backgroundColor: theme.palette.action.hover,
fontWeight: 600,
},
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: 1,
},
'& code': {
backgroundColor: theme.palette.action.hover,
padding: '2px 6px',
borderRadius: 1,
fontSize: '0.875em',
},
'& pre': {
backgroundColor: theme.palette.action.hover,
padding: theme.spacing(2),
borderRadius: 1,
overflow: 'auto',
},
'& ul, & ol': {
pl: 3,
mb: 1,
},
'& blockquote': {
borderLeft: `4px solid ${theme.palette.primary.main}`,
pl: 2,
ml: 0,
color: theme.palette.text.secondary,
},
})}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(pageQuery.data.data.html),
}}
/>
</Box>
) : (
<Typography color="text.secondary">
Seite nicht gefunden.
</Typography>
)}
</Paper>
</Box>
</DashboardLayout>
);
}