./knowledge-base/frontend/src/pages/UploadPage.tsx
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Container,
Box,
Typography,
TextField,
Button,
Chip,
AppBar,
Toolbar,
Paper,
Alert,
CircularProgress,
LinearProgress,
ToggleButtonGroup,
ToggleButton,
Autocomplete,
} from "@mui/material";
import { DocumentGenreSelector } from "../components/DocumentGenreSelector";
import {
Upload as UploadIcon,
Home as HomeIcon,
Image as ImageIcon,
PictureAsPdf as PdfIcon,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { bulkUpload, uploadPdf, fetchPrograms } from "../api/client";
import type {
BulkUploadRequest,
BookMetadata,
PageMetadata,
PdfUploadUrlRequest,
ProgramMaster,
} from "../types";
import { EraSelector } from "../components/EraSelector";
type UploadMode = "image" | "pdf";
export default function UploadPage() {
const navigate = useNavigate();
const [uploadMode, setUploadMode] = useState<UploadMode>("pdf");
// ドキュメントIDはUUIDで自動生成
const [documentId, setDocumentId] = useState(() => crypto.randomUUID());
const [title, setTitle] = useState("");
// 時代設定モード: "manual" = 手動設定、"auto" = 自動設定(AI判定)
const [eraMode, setEraMode] = useState<"manual" | "auto">("manual");
const [eraLabels, setEraLabels] = useState<string[]>([]);
const [documentGenre, setDocumentGenre] = useState("");
const [selectedPrograms, setSelectedPrograms] = useState<string[]>([]);
const [tags, setTags] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [pdfProgress, setPdfProgress] = useState<number>(0);
const [pdfStatus, setPdfStatus] = useState<string>("");
// 番組マスタを読み込み
const { data: programList = [] } = useQuery<ProgramMaster[]>({
queryKey: ["programs"],
queryFn: fetchPrograms,
staleTime: 5 * 60 * 1000,
});
// 画像一括アップロード処理
const imageMutation = useMutation({
mutationFn: (params: BulkUploadRequest) => bulkUpload(params),
onSuccess: (data) => {
alert(
`アップロード完了: 成功 ${data.success_count}件 / 失敗 ${data.failure_count}件`,
);
resetForm();
},
onError: (error) => {
console.error("Upload error:", error);
alert("アップロードに失敗しました");
},
});
// PDFアップロード処理
const pdfMutation = useMutation({
mutationFn: async (params: {
request: PdfUploadUrlRequest;
file: File;
}) => {
setPdfStatus("PDFをアップロード中...");
setPdfProgress(0);
const result = await uploadPdf(params.request, params.file, (percent) => {
setPdfProgress(percent);
setPdfStatus(`PDFをアップロード中... ${percent}%`);
});
setPdfStatus(
"✅ アップロード完了。バックグラウンドでページ分割・解析が開始されます。",
);
return result;
},
onSuccess: () => {
setTimeout(() => {
resetForm();
}, 3000);
},
onError: (error) => {
console.error("PDF upload error:", error);
setPdfStatus("❌ アップロードに失敗しました");
alert("PDFのアップロードに失敗しました");
},
});
const resetForm = () => {
setFiles([]);
setDocumentId(crypto.randomUUID());
setTitle("");
setEraMode("manual");
setEraLabels([]);
setDocumentGenre("");
setSelectedPrograms([]);
setTags("");
setPdfProgress(0);
setPdfStatus("");
};
const buildBookMetadata = (): BookMetadata => ({
title,
era_labels: eraMode === "auto" ? [] : eraLabels,
document_genre: documentGenre,
programs: selectedPrograms,
});
const buildPageMetadata = (): Partial<PageMetadata> => ({
tags: tags
.split(",")
.map((t) => t.trim())
.filter(Boolean),
});
const validateForm = () => {
const eraOk = eraMode === "auto" || eraLabels.length > 0;
if (!title || !documentGenre || !eraOk) {
alert(
"タイトル、資料ジャンル、時代タグ(自動設定の場合は不要)は必須です",
);
return false;
}
if (files.length === 0) {
alert("ファイルを選択してください");
return false;
}
return true;
};
const handleImageUpload = async () => {
if (!validateForm()) return;
const items = await Promise.all(
files.map(async (file, index) => {
const base64 = await fileToBase64(file);
return {
document_id: documentId,
page_number: index + 1,
image_data: base64,
};
}),
);
const request: BulkUploadRequest = {
book_metadata: buildBookMetadata(),
items,
metadata: buildPageMetadata(),
};
imageMutation.mutate(request);
};
const handlePdfUpload = () => {
if (!validateForm()) return;
if (
files[0].type !== "application/pdf" &&
!files[0].name.toLowerCase().endsWith(".pdf")
) {
alert("PDFファイルを選択してください");
return;
}
const request: PdfUploadUrlRequest = {
document_id: documentId,
filename: files[0].name,
book_metadata: buildBookMetadata(),
};
pdfMutation.mutate({ request, file: files[0] });
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
setFiles(Array.from(event.target.files));
}
};
const isLoading = imageMutation.isPending || pdfMutation.isPending;
return (
<>
{/* ヘッダー */}
<AppBar position="static">
<Toolbar>
<Button
color="inherit"
startIcon={<HomeIcon />}
onClick={() => navigate("/")}
>
ホーム
</Button>
<Box
component="img"
src="/img/fav.png"
alt="ロゴ"
sx={{ height: 28, width: 28, mr: 1, ml: 1, borderRadius: 1 }}
/>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
時代考証システム - アップロード
</Typography>
</Toolbar>
</AppBar>
{/* メインコンテンツ */}
<Container maxWidth="md" sx={{ mt: 4, mb: 4 }}>
<Paper sx={{ p: 4 }}>
<Typography variant="h5" gutterBottom>
資料アップロード
</Typography>
{/* アップロードモード切り替え */}
<Box sx={{ mb: 3 }}>
<ToggleButtonGroup
value={uploadMode}
exclusive
onChange={(_, value) => {
if (value) {
setUploadMode(value);
setFiles([]);
}
}}
aria-label="アップロードモード"
>
<ToggleButton value="image" aria-label="画像">
<ImageIcon sx={{ mr: 1 }} />
画像(複数枚)
</ToggleButton>
<ToggleButton value="pdf" aria-label="PDF">
<PdfIcon sx={{ mr: 1 }} />
PDFファイル(1冊)
</ToggleButton>
</ToggleButtonGroup>
</Box>
<Alert severity="info" sx={{ mb: 3 }}>
{uploadMode === "image"
? "※ 選択した全画像に同じメタデータが適用されます"
: "※ PDFを1ファイル選択してください。各ページが自動的に分割・解析されます。ページ番号はPDF内のページ順に記録されます。"}
</Alert>
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{/* タイトル */}
<TextField
fullWidth
label="タイトル(書籍タイトル)"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="例: 昭和の生活写真集"
required
/>
{/* 時代タグ(複数可) */}
<Box>
<Typography variant="subtitle2" gutterBottom>
時代タグ <span style={{ color: "red" }}>*</span>
</Typography>
{/* 手動設定 / 自動設定 トグル */}
<ToggleButtonGroup
value={eraMode}
exclusive
size="small"
onChange={(_, v) => {
if (v) setEraMode(v);
}}
sx={{ mb: 1.5 }}
>
<ToggleButton value="manual">手動設定</ToggleButton>
<ToggleButton value="auto">自動設定</ToggleButton>
</ToggleButtonGroup>
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mb: 1 }}
>
{eraMode === "auto"
? "AIがページごとに時代を自動判定します。時代タグの入力は不要です。"
: "室町以前~平成~の9開期から該当するものを選んでください。複数選択可能です。"}
</Typography>
<EraSelector
value={eraLabels}
onChange={setEraLabels}
disabled={eraMode === "auto"}
/>
</Box>
{/* 資料ジャンル */}
<Box>
<Typography variant="subtitle2" gutterBottom>
資料ジャンル <span style={{ color: "red" }}>*</span>
</Typography>
<DocumentGenreSelector
value={documentGenre}
onChange={setDocumentGenre}
/>
</Box>
{/* タグ */}
<TextField
fullWidth
label="タグ(カンマ区切り)"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="例: 台所, 家電, レトロ"
/>
{/* 番組名 */}
<Autocomplete
multiple
disableCloseOnSelect
options={programList.map((p) => p.name)}
value={selectedPrograms}
onChange={(_, newValue) => setSelectedPrograms(newValue)}
renderInput={(params) => (
<TextField
{...params}
label="番組名(任意)"
placeholder="番組を選択してください"
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={option}
size="small"
{...getTagProps({ index })}
key={option}
/>
))
}
noOptionsText="番組が登録されていません"
/>
{/* ファイル選択 */}
<Box>
<Button
variant="outlined"
component="label"
fullWidth
sx={{
height: 56,
borderStyle: uploadMode === "pdf" ? "dashed" : "solid",
}}
disabled={isLoading}
>
{uploadMode === "image"
? `画像を選択 (${files.length}件)`
: files.length > 0
? `選択済み: ${files[0].name}`
: "PDFを選択"}
<input
type="file"
hidden
multiple={uploadMode === "image"}
accept={
uploadMode === "image" ? "image/*" : "application/pdf,.pdf"
}
onChange={handleFileChange}
/>
</Button>
{files.length > 0 && uploadMode === "image" && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 1 }}
>
選択されたファイル: {files.map((f) => f.name).join(", ")}
</Typography>
)}
</Box>
{/* PDFアップロード進捗 */}
{uploadMode === "pdf" && pdfMutation.isPending && (
<Box>
<LinearProgress
variant="determinate"
value={pdfProgress}
sx={{ mb: 1 }}