./knowledge-base/frontend/src/components/SearchDialogs.tsx
import { useState, useEffect } from "react";
import {
Box,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
Grid,
Card,
CardMedia,
CardContent,
CardActionArea,
Chip,
Divider,
Button,
TextField,
Autocomplete,
CircularProgress,
Alert,
Snackbar,
FormControl,
Select,
MenuItem,
InputLabel,
Paper,
} from "@mui/material";
import {
Close as CloseIcon,
MenuBook as MenuBookIcon,
Edit as EditIcon,
Save as SaveIcon,
Cancel as CancelIcon,
ArrowBack as ArrowBackIcon,
} from "@mui/icons-material";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import type {
SearchResultItem,
ProgramMaster,
DocumentSummary,
} from "../types";
import type { BookGroup } from "./SearchCards";
import { EraSelector } from "./EraSelector";
import {
updatePageMetadata,
updateDocumentMetadata,
fetchPrograms,
searchPages,
} from "../api/client";
const DOC_GENRE_OPTIONS = [
"写真集",
"ノンフィクション小説",
"辞書",
"年表",
"メール・議事録",
];
/** BookPagesDialog 内で書籍メタ情報を編集するパネル */
function BookMetaEditPanel({
doc,
programs: programMasters,
onClose,
onSaved,
}: {
doc: DocumentSummary;
programs: ProgramMaster[];
onClose: () => void;
onSaved: () => void;
}) {
const queryClient = useQueryClient();
const [title, setTitle] = useState(doc.title ?? "");
const [docGenre, setDocGenre] = useState(doc.document_genre ?? "");
const [selectedPrograms, setSelectedPrograms] = useState<string[]>(
doc.programs ?? [],
);
const [eraLabels, setEraLabels] = useState<string[]>(doc.era_labels ?? []);
const [snack, setSnack] = useState<{
open: boolean;
msg: string;
severity: "success" | "error";
}>({ open: false, msg: "", severity: "success" });
const saveMutation = useMutation({
mutationFn: () =>
updateDocumentMetadata(doc.document_id, {
title,
document_genre: docGenre,
programs: selectedPrograms,
era_labels: eraLabels,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["search"] });
queryClient.invalidateQueries({ queryKey: ["browse"] });
queryClient.invalidateQueries({ queryKey: ["maintenance-documents"] });
setSnack({ open: true, msg: "保存しました", severity: "success" });
onSaved();
},
onError: () => {
setSnack({ open: true, msg: "保存に失敗しました", severity: "error" });
},
});
return (
<Paper
variant="outlined"
sx={{ m: 2, p: 2, display: "flex", flexDirection: "column", gap: 2 }}
>
<Typography variant="subtitle2" color="primary" fontWeight="bold">
書籍メタ情報を編集
</Typography>
<Typography variant="body2" color="text.secondary">
このタイトルに属するすべてのページに一括で適用されます。
</Typography>
<TextField
label="タイトル"
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
size="small"
/>
<FormControl fullWidth size="small">
<InputLabel>資料ジャンル</InputLabel>
<Select
value={docGenre}
label="資料ジャンル"
onChange={(e) => setDocGenre(e.target.value)}
>
<MenuItem value="">(未設定)</MenuItem>
{DOC_GENRE_OPTIONS.map((g) => (
<MenuItem key={g} value={g}>
{g}
</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
multiple
disableCloseOnSelect
options={programMasters.map((p) => p.name)}
value={selectedPrograms}
onChange={(_, v) => setSelectedPrograms(v)}
renderInput={(params) => (
<TextField {...params} label="番組" size="small" />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={option}
size="small"
{...getTagProps({ index })}
key={option}
/>
))
}
/>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
時代タグ
</Typography>
<EraSelector value={eraLabels} onChange={setEraLabels} />
</Box>
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
<Button
onClick={onClose}
startIcon={<CancelIcon />}
disabled={saveMutation.isPending}
>
キャンセル
</Button>
<Button
variant="contained"
startIcon={
saveMutation.isPending ? (
<CircularProgress size={16} />
) : (
<SaveIcon />
)
}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? "保存中..." : "保存"}
</Button>
</Box>
<Snackbar
open={snack.open}
autoHideDuration={3000}
onClose={() => setSnack((s) => ({ ...s, open: false }))}
>
<Alert
severity={snack.severity}
onClose={() => setSnack((s) => ({ ...s, open: false }))}
>
{snack.msg}
</Alert>
</Snackbar>
</Paper>
);
}
export function BookPagesDialog({
book,
onClose,
onPageClick,
}: {
book: BookGroup | null;
onClose: () => void;
onPageClick: (item: SearchResultItem) => void;
}) {
const [editMode, setEditMode] = useState(false);
// book が変わったら編集モードをリセット
useEffect(() => {
setEditMode(false);
}, [book]);
// 書籍の全ページを取得(検索結果は最大100件のため、document_idで全件取得)
const { data: allPagesData, isLoading: pagesLoading } = useQuery({
queryKey: ["bookAllPages", book?.document_id],
queryFn: () =>
searchPages({
query: "",
filters: { document_id: book!.document_id },
page: 1,
page_size: 1000,
}),
enabled: !!book,
staleTime: 2 * 60 * 1000,
});
// 番組マスタ(編集パネル用)
const { data: programList = [] } = useQuery<ProgramMaster[]>({
queryKey: ["programs"],
queryFn: fetchPrograms,
staleTime: 5 * 60 * 1000,
enabled: !!book && editMode,
});
if (!book) return null;
// 全ページ取得できていればそちらを使用(ロード中は空配列)
const allPages = allPagesData?.results ?? (pagesLoading ? [] : book.pages);
const sortedPages = [...allPages].sort(
(a, b) => a.page_number - b.page_number,
);
// 書籍の代表ページから DocumentSummary 相当を組み立てる
const docSummary: DocumentSummary = {
document_id: book.document_id,
title: book.title,
programs: book.thumbnailItem.programs ?? [],
document_genre: book.thumbnailItem.document_genre ?? "",
era_labels: book.thumbnailItem.era_labels ?? [],
};
return (
<Dialog
open={!!book}
onClose={editMode ? undefined : onClose}
maxWidth="md"
fullWidth
scroll="paper"
>
<DialogTitle sx={{ pr: editMode ? 2 : 10 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<MenuBookIcon color="primary" />
<span>{book.title}</span>
</Box>
{!editMode && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
全
{allPagesData
? sortedPages.length
: (book.thumbnailItem.pdf_total_pages ?? sortedPages.length)}
ページ
</Typography>
)}
{!editMode && (
<IconButton
onClick={() => setEditMode(true)}
sx={{ position: "absolute", right: 44, top: 8 }}
aria-label="書籍メタ情報を編集"
title="書籍メタ情報を編集"
>
<EditIcon />
</IconButton>
)}
<IconButton
onClick={onClose}
sx={{ position: "absolute", right: 8, top: 8 }}
aria-label="閉じる"
>
<CloseIcon />
</IconButton>
</DialogTitle>
{editMode && (
<BookMetaEditPanel
doc={docSummary}
programs={programList}
onClose={() => setEditMode(false)}
onSaved={() => setEditMode(false)}
/>
)}
<DialogContent dividers>
{pagesLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress />
</Box>
) : (
<Grid container spacing={2}>
{sortedPages.map((item) => (
<Grid item xs={12} sm={6} md={4} key={item.page_id}>
<Card sx={{ height: "100%" }}>
<CardActionArea
onClick={() => !editMode && onPageClick(item)}
>
<CardMedia
component="img"
height="150"
image={item.thumbnail_url || item.image_url}
alt={`p.${item.page_number}`}
sx={{ objectFit: "cover" }}
/>
<CardContent>
<Typography variant="body2" fontWeight="bold">
p.{item.page_number}
</Typography>
{item.ocr_supplemental_info &&
item.ocr_supplemental_info.length > 0 && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
{item.ocr_supplemental_info[0].substring(0, 60)}
{item.ocr_supplemental_info[0].length > 60
? "…"
: ""}
</Typography>
)}
</CardContent>
</CardActionArea>
</Card>
</Grid>
))}
</Grid>
)}
</DialogContent>
</Dialog>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<Typography
variant="subtitle1"
fontWeight="bold"
gutterBottom
sx={{ mt: 1, color: "primary.main" }}
>
{children}
</Typography>
);
}
function MetaRow({
label,
value,
multiline,
}: {
label: string;
value?: React.ReactNode;
multiline?: boolean;
}) {
if (!value && value !== 0) return null;
return (
<Box
sx={{
display: "flex",
mb: 1,
gap: 1,
alignItems: multiline ? "flex-start" : "center",
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ minWidth: 120, flexShrink: 0, fontWeight: 500 }}
>
{label}