./knowledge-base/frontend/src/pages/SearchPage.tsx
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Container,
Box,
Typography,
TextField,
Button,
Grid,
Card,
CardActionArea,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
CircularProgress,
AppBar,
Toolbar,
Switch,
FormControlLabel,
Paper,
} from "@mui/material";
import {
Search as SearchIcon,
Upload as UploadIcon,
Home as HomeIcon,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import {
searchPages,
loadEraMasterList,
fetchPrograms,
fetchBookCounts,
fetchAllDocuments,
} from "../api/client";
import type {
SearchRequest,
SearchResultItem,
ProgramMaster,
DocumentSummary,
} from "../types";
import { ERA_LABELS } from "../utils/eraUtils";
import {
type BookGroup,
SearchResultCard,
BookGroupCard,
} from "../components/SearchCards";
import { DetailDialog, BookPagesDialog } from "../components/SearchDialogs";
import {
BrowseEraList,
BrowseProgramList,
BrowseBookTitleList,
} from "../components/BrowseLists";
import { DOCUMENT_GENRE_OPTIONS } from "../components/DocumentGenreSelector";
const UNSET_FILTER_VALUE = "__UNSET__";
const UNSET_LABEL = "未設定";
function normalizeFilterKey(value: string): string {
return value.trim().replace(/戰/g, "戦");
}
export default function SearchPage() {
const navigate = useNavigate();
// ---- 検索フォーム state ----
const [query, setQuery] = useState("");
const [searchTitle, setSearchTitle] = useState("");
const [selectedProgram, setSelectedProgram] = useState("");
// 時代:9ラベルから複数選択
const [selectedEraLabels, setSelectedEraLabels] = useState<string[]>([]);
const [searchParams, setSearchParams] = useState<SearchRequest | null>(null);
const [detailItem, setDetailItem] = useState<SearchResultItem | null>(null);
const [groupByBook, setGroupByBook] = useState(true);
const [selectedBook, setSelectedBook] = useState<BookGroup | null>(null);
// DetailDialog が BookPagesDialog 経由で開かれた場合 true(「一覧に戻る」表示制御)
const [detailFromBook, setDetailFromBook] = useState(false);
// ブラウズモード
const [browseMode, setBrowseMode] = useState<
null | "era" | "program" | "book" | "genre"
>(null);
const [browseEra, setBrowseEra] = useState<string | null>(null);
const [browseProgram, setBrowseProgram] = useState<string | null>(null);
const [browseBook, setBrowseBook] = useState<string | null>(null);
// 書籍タイトルブラウズ内のジャンル絞り込み state
// null → ジャンル選択画面、文字列 → 書籍一覧画面
const [browseBookGenre, setBrowseBookGenre] = useState<string | null>(null);
// 検索フォームの資料ジャンルフィルタ
const [selectedDocGenre, setSelectedDocGenre] = useState("");
// 時代マスタを読み込み
const { data: eraList = [] } = useQuery({
queryKey: ["era-master"],
queryFn: loadEraMasterList,
staleTime: Infinity,
});
// 番組マスタを読み込み
const { data: programList = [] } = useQuery<ProgramMaster[]>({
queryKey: ["programs"],
queryFn: fetchPrograms,
staleTime: 5 * 60 * 1000,
});
// 書籍タイトル一覧(書籍タイトルで検索・ジャンルで検索用)
const { data: documentList = [], isLoading: documentListLoading } = useQuery<
DocumentSummary[]
>({
queryKey: ["all-documents"],
queryFn: fetchAllDocuments,
staleTime: 5 * 60 * 1000,
enabled: browseMode === "book" || browseMode === "genre",
});
// 書籍数集計(時代・番組名)
const { data: bookCounts } = useQuery({
queryKey: ["book-counts"],
queryFn: fetchBookCounts,
staleTime: 60 * 1000,
});
const unsetFilterValue = bookCounts?.unset_filter_value || UNSET_FILTER_VALUE;
const eraCountMap = useMemo(() => {
const map = new Map<string, number>();
for (const item of bookCounts?.era_counts || []) {
map.set(item.filter_value, item.count);
map.set(normalizeFilterKey(item.filter_value), item.count);
}
return map;
}, [bookCounts]);
const programCountMap = useMemo(() => {
const map = new Map<string, number>();
for (const item of bookCounts?.program_counts || []) {
map.set(item.filter_value, item.count);
map.set(normalizeFilterKey(item.filter_value), item.count);
}
return map;
}, [bookCounts]);
// ブラウズモード検索パラメータ
const browseSearchRequest = useMemo<SearchRequest | null>(() => {
if (browseMode === "era" && browseEra) {
const filters: SearchRequest["filters"] = { era: browseEra };
return { query: "", filters, page: 1, page_size: 100 };
}
if (browseMode === "program" && browseProgram) {
return {
query: "",
filters: { programs: [browseProgram] },
page: 1,
page_size: 100,
};
}
return null;
}, [browseMode, browseEra, browseProgram]);
// 検索実行
const {
data: searchResults,
isLoading,
error,
} = useQuery({
queryKey: ["search", searchParams],
queryFn: () => searchPages(searchParams!),
enabled: !!searchParams,
});
// 書籍グループ化(document_id 単位でまとめ、page_number 最小を代表サムネにする)
const bookGroups = useMemo<BookGroup[]>(() => {
if (!searchResults) return [];
const map = new Map<string, BookGroup>();
for (const item of searchResults.results) {
const key = item.document_id;
if (!map.has(key)) {
map.set(key, {
document_id: key,
title: item.title,
thumbnailItem: item,
pages: [item],
});
} else {
const group = map.get(key)!;
group.pages.push(item);
// page_number が小さい方を代表サムネに
if (item.page_number < group.thumbnailItem.page_number) {
group.thumbnailItem = item;
}
}
}
// ページ数降順で並べ替え(ヒット数が多い書籍を上位に)
return Array.from(map.values()).sort(
(a, b) => b.pages.length - a.pages.length,
);
}, [searchResults]);
// ブラウズモード検索実行
const { data: browseResults, isLoading: browseLoading } = useQuery({
queryKey: ["browse", browseSearchRequest],
queryFn: () => searchPages(browseSearchRequest!),
enabled: !!browseSearchRequest,
});
// ブラウズモード書籍グループ化
const browseBookGroups = useMemo<BookGroup[]>(() => {
if (!browseResults) return [];
const map = new Map<string, BookGroup>();
for (const item of browseResults.results) {
const key = item.document_id;
if (!map.has(key)) {
map.set(key, {
document_id: key,
title: item.title,
thumbnailItem: item,
pages: [item],
});
} else {
const group = map.get(key)!;
group.pages.push(item);
if (item.page_number < group.thumbnailItem.page_number) {
group.thumbnailItem = item;
}
}
}
return Array.from(map.values()).sort(
(a, b) => b.pages.length - a.pages.length,
);
}, [browseResults]);
const handleSearch = () => {
// ブラウズモードを解除して通常検索に切り替え
setBrowseMode(null);
setBrowseEra(null);
setBrowseProgram(null);
setBrowseBook(null);
setBrowseBookGenre(null);
const filters: SearchRequest["filters"] = {};
// タイトルフィルタ
if (searchTitle.trim()) filters.title = searchTitle.trim();
// 番組フィルタ
if (selectedProgram) filters.programs = [selectedProgram];
// 資料ジャンルフィルタ
if (selectedDocGenre) filters.document_genre = selectedDocGenre;
// 時代フィルタ:選択済みラベル配列を渡す
if (selectedEraLabels.length > 0) filters.era = selectedEraLabels;
const params: SearchRequest = {
query,
filters: Object.keys(filters).length > 0 ? filters : undefined,
page: 1,
page_size: groupByBook ? 100 : 20,
};
setSearchParams(params);
};
const browseEraLabel =
browseEra === unsetFilterValue ? UNSET_LABEL : browseEra || "";
const browseProgramLabel =
browseProgram === unsetFilterValue ? UNSET_LABEL : browseProgram || "";
return (
<>
{/* ヘッダー */}
<AppBar position="static">
<Toolbar>
<Button
color="inherit"
startIcon={<HomeIcon />}
onClick={() => navigate("/")}
sx={{ mr: 1 }}
>
ホーム
</Button>
<Box
component="img"
src="/img/fav.png"
alt="ロゴ"
sx={{ height: 28, width: 28, mr: 1, borderRadius: 1 }}
/>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
時代考証システム - 検索
</Typography>
<Button
color="inherit"
startIcon={<UploadIcon />}
onClick={() => navigate("/upload")}
>
アップロード
</Button>
</Toolbar>
</AppBar>
{/* メインコンテンツ */}
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
{/* ━━━ 統合検索フォーム ━━━ */}
<Paper elevation={2} sx={{ p: 3, mb: 3 }}>
<Grid container spacing={2}>
{/* キーワード */}
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="キーワード"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="例: 昭和時代の台所"
/>
</Grid>
{/* タイトル */}
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="タイトル(書籍名)"
value={searchTitle}
onChange={(e) => setSearchTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="例: 江戸風俗図説"
/>
</Grid>
{/* 番組名 */}
<Grid item xs={12} md={4}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box
component="img"
src="/img/TV.png"
alt="番組名"
sx={{
width: 32,
height: 32,
objectFit: "contain",
flexShrink: 0,
}}
/>
<FormControl fullWidth>
<InputLabel>番組名</InputLabel>
<Select
value={selectedProgram}
onChange={(e) => setSelectedProgram(e.target.value)}
label="番組名"
>
<MenuItem value="">すべて</MenuItem>
{programList.map((p) => (
<MenuItem key={p.id} value={p.name}>
{p.name}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Grid>
{/* 時代 セクション */}
<Grid item xs={12}>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1 }}>
<Box
component="img"
src="/img/kabuto.png"
alt="時代"
sx={{
width: 32,
height: 32,
objectFit: "contain",
flexShrink: 0,
mt: 0.5,
}}
/>
<Box sx={{ flexGrow: 1 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5 }}
>
時代(複数選択可・OR条件)
</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
{ERA_LABELS.map((label) => (
<Chip
key={label}
label={label}
onClick={() =>
setSelectedEraLabels((prev) =>
prev.includes(label)
? prev.filter((l) => l !== label)
: [...prev, label],
)
}
color={
selectedEraLabels.includes(label)
? "primary"