adding chat features, admin features and bug fixes
This commit is contained in:
240
frontend/src/pages/Wissen.tsx
Normal file
240
frontend/src/pages/Wissen.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Typography,
|
||||
Paper,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { Search as SearchIcon } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import DOMPurify from 'dompurify';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { bookstackApi } from '../services/bookstack';
|
||||
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={{ 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) => (
|
||||
<ListItem key={item.id} disablePadding>
|
||||
<ListItemButton
|
||||
selected={selectedPageId === item.id}
|
||||
onClick={() => handleSelectPage(item.id)}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.name}
|
||||
secondary={
|
||||
'book' in item && item.book
|
||||
? item.book.name
|
||||
: 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>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{pageQuery.data.data.name}
|
||||
</Typography>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user