new features, bookstack

This commit is contained in:
Matthias Hochmeister
2026-03-03 21:30:38 +01:00
parent 817329db70
commit d3561c1109
32 changed files with 1923 additions and 207 deletions

View File

@@ -0,0 +1,138 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Divider,
Skeleton,
} from '@mui/material';
import { MenuBook } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
import { bookstackApi } from '../../services/bookstack';
import type { BookStackPage } from '../../types/bookstack.types';
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
page,
showDivider,
}) => {
const handleClick = () => {
window.open(page.url, '_blank', 'noopener,noreferrer');
};
const relativeTime = page.updated_at
? formatDistanceToNow(new Date(page.updated_at), { addSuffix: true, locale: de })
: null;
return (
<>
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{page.name}
</Typography>
{page.book && (
<Typography variant="body2" color="text.secondary" noWrap sx={{ mt: 0.25 }}>
{page.book.name}
</Typography>
)}
</Box>
{relativeTime && (
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
>
{relativeTime}
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
);
};
const BookStackRecentWidget: React.FC = () => {
const { data, isLoading, isError } = useQuery({
queryKey: ['bookstack-recent'],
queryFn: () => bookstackApi.getRecent(),
refetchInterval: 5 * 60 * 1000,
retry: 1,
});
const configured = data?.configured ?? true;
const pages = data?.data ?? [];
if (!configured) return null;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
BookStack Neueste Seiten
</Typography>
</Box>
{isLoading && (
<Box>
{[1, 2, 3, 4, 5].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="70%" height={22} />
<Skeleton variant="text" width="50%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
BookStack nicht erreichbar
</Typography>
)}
{!isLoading && !isError && pages.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Seiten gefunden
</Typography>
)}
{!isLoading && !isError && pages.length > 0 && (
<Box>
{pages.map((page, index) => (
<PageRow
key={page.id}
page={page}
showDivider={index < pages.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default BookStackRecentWidget;

View File

@@ -0,0 +1,152 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Divider,
CircularProgress,
InputAdornment,
} from '@mui/material';
import { Search, MenuBook } from '@mui/icons-material';
import { bookstackApi } from '../../services/bookstack';
import type { BookStackSearchResult } from '../../types/bookstack.types';
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim();
}
const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean }> = ({
result,
showDivider,
}) => {
const preview = result.preview_html?.content ? stripHtml(result.preview_html.content) : '';
return (
<>
<Box
onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')}
sx={{
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Typography variant="subtitle2" noWrap>
{result.name}
</Typography>
{preview && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 0.25,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{preview}
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
);
};
const BookStackSearchWidget: React.FC = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<BookStackSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [configured, setConfigured] = useState(true);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!query.trim()) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
debounceRef.current = setTimeout(async () => {
try {
const response = await bookstackApi.search(query.trim());
setConfigured(response.configured);
setResults(response.data);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
if (!configured) return null;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
BookStack Suche
</Typography>
</Box>
<TextField
fullWidth
size="small"
placeholder="Suchbegriff eingeben..."
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{loading ? <CircularProgress size={16} /> : <Search fontSize="small" />}
</InputAdornment>
),
}}
/>
{!loading && query.trim() && results.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Ergebnisse für {query}"
</Typography>
)}
{results.length > 0 && (
<Box sx={{ mt: 1 }}>
{results.map((result, index) => (
<ResultRow
key={result.id}
result={result}
showDivider={index < results.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
);
};
export default BookStackSearchWidget;