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;

View File

@@ -14,6 +14,7 @@ import {
} from '@mui/material';
import { incidentsApi, EINSATZ_ARTEN, EINSATZ_ART_LABELS, CreateEinsatzPayload } from '../../services/incidents';
import { useNotification } from '../../contexts/NotificationContext';
import { toGermanDateTime, fromGermanDateTime } from '../../utils/dateInput';
interface CreateEinsatzDialogProps {
open: boolean;
@@ -21,16 +22,16 @@ interface CreateEinsatzDialogProps {
onSuccess: () => void;
}
// Default alarm_time = now (rounded to minute)
function nowISO(): string {
// Default alarm_time = now (rounded to minute) in DD.MM.YYYY HH:MM format
function nowGerman(): string {
const d = new Date();
d.setSeconds(0, 0);
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
return toGermanDateTime(d.toISOString());
}
const INITIAL_FORM: CreateEinsatzPayload & { alarm_time_local: string } = {
alarm_time: '',
alarm_time_local: nowISO(),
alarm_time_local: nowGerman(),
einsatz_art: 'Brand',
einsatz_stichwort: '',
strasse: '',
@@ -47,7 +48,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
onSuccess,
}) => {
const notification = useNotification();
const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowISO() });
const [form, setForm] = useState({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -74,8 +75,9 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
setLoading(true);
try {
// Convert local datetime string to UTC ISO string
const isoLocal = fromGermanDateTime(form.alarm_time_local);
const payload: CreateEinsatzPayload = {
alarm_time: new Date(form.alarm_time_local).toISOString(),
alarm_time: isoLocal ? new Date(isoLocal).toISOString() : new Date().toISOString(),
einsatz_art: form.einsatz_art,
einsatz_stichwort: form.einsatz_stichwort || null,
strasse: form.strasse || null,
@@ -88,7 +90,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
await incidentsApi.create(payload);
notification.showSuccess('Einsatz erfolgreich angelegt');
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
onSuccess();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Fehler beim Anlegen des Einsatzes';
@@ -101,7 +103,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
const handleClose = () => {
if (loading) return;
setError(null);
setForm({ ...INITIAL_FORM, alarm_time_local: nowISO() });
setForm({ ...INITIAL_FORM, alarm_time_local: nowGerman() });
onClose();
};
@@ -132,7 +134,7 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
<TextField
label="Alarmzeit *"
name="alarm_time_local"
type="datetime-local"
placeholder="TT.MM.JJJJ HH:MM"
value={form.alarm_time_local}
onChange={handleChange}
InputLabelProps={{ shrink: true }}
@@ -141,7 +143,6 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
helperText="DD.MM.YYYY HH:mm"
inputProps={{
'aria-label': 'Alarmzeit',
// HTML datetime-local uses YYYY-MM-DDTHH:mm format
}}
/>
</Grid>