new features, bookstack
This commit is contained in:
138
frontend/src/components/dashboard/BookStackRecentWidget.tsx
Normal file
138
frontend/src/components/dashboard/BookStackRecentWidget.tsx
Normal 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;
|
||||
152
frontend/src/components/dashboard/BookStackSearchWidget.tsx
Normal file
152
frontend/src/components/dashboard/BookStackSearchWidget.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user