import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog' import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPost } from '@/lib/api' import { canEditContent } from '@/lib/users' import { loadPostImportSession, creatableImportRows, mergeImportResults, mergeValidatedImportRows, processableImportRows, reviewSummaryCounts, savePostImportSession, type PostImportResultRow, type PostImportRow, type PostImportSession } from '@/lib/postImportSession' import Forbidden from '@/pages/Forbidden' import type { FC } from 'react' import type { User } from '@/types' type Props = { user: User | null } type Draft = { url: string title: string thumbnailBase: string originalCreatedFrom: string originalCreatedBefore: string duration: string tags: string parentPostIds: string } const PostImportReviewPage: FC = ({ user }) => { const editable = canEditContent (user) const navigate = useNavigate () const { sessionId } = useParams () const [searchParams, setSearchParams] = useSearchParams () const [session, setSession] = useState (null) const [loading, setLoading] = useState (false) const [missing, setMissing] = useState (false) const [savingRow, setSavingRow] = useState (null) useEffect (() => { if (sessionId == null) return const loaded = loadPostImportSession (sessionId, message => toast ({ title: '取込状態を復元できませんでした', description: message })) setSession (loaded) setMissing (loaded == null) }, [sessionId]) useEffect (() => { if (sessionId == null || session == null) return savePostImportSession (sessionId, session, message => toast ({ title: '取込状態を保存できませんでした', description: message })) }, [session, sessionId]) const rows = session?.rows ?? [] const editingSourceRow = Number (searchParams.get ('edit') ?? '') const editingRow = Number.isFinite (editingSourceRow) ? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null : null const counts = useMemo (() => reviewSummaryCounts (rows), [rows]) const processable = useMemo ( () => processableImportRows (rows), [rows]) const creatable = useMemo ( () => creatableImportRows (rows), [rows]) const reviewRows = session?.repairMode === 'failed' ? [...rows].sort ((a, b) => { const aFailed = a.importStatus === 'failed' ? 0 : 1 const bFailed = b.importStatus === 'failed' ? 0 : 1 return aFailed - bFailed || a.sourceRow - b.sourceRow }) : rows useEffect (() => { if (editingRow == null || session?.repairMode !== 'failed') return const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`) element?.scrollIntoView ({ block: 'center', behavior: 'smooth' }) }, [editingRow, session?.repairMode]) const updateSessionRows = (nextRows: PostImportRow[]) => setSession (current => current ? { ...current, rows: nextRows } : current) const saveDraft = async (draft: Draft): Promise => { if (session == null) return false if (editingRow == null) return false const urlChanged = draft.url !== editingRow.url const nextProvenance = { ...editingRow.provenance } const nextAttributes = { ...editingRow.attributes } const nextTagSources = { automatic: editingRow.tagSources?.automatic ?? '', manual: editingRow.tagSources?.manual ?? '' } const draftFields = [ ['title', draft.title], ['thumbnailBase', draft.thumbnailBase], ['originalCreatedFrom', draft.originalCreatedFrom], ['originalCreatedBefore', draft.originalCreatedBefore], ['duration', draft.duration], ['parentPostIds', draft.parentPostIds]] as const draftFields.forEach (([field, value]) => { nextAttributes[field] = value nextProvenance[field] = value !== String (editingRow.attributes[field] ?? '') ? 'manual' : (editingRow.provenance[field] ?? 'automatic') }) nextAttributes.tags = draft.tags if (draft.tags !== String (editingRow.attributes.tags ?? '')) { nextProvenance.tags = 'manual' nextTagSources.manual = draft.tags } else { nextProvenance.tags = editingRow.provenance.tags ?? 'automatic' nextTagSources.manual = editingRow.tagSources?.manual ?? '' } setSavingRow (editingRow.sourceRow) const nextRows = session.rows.map (row => row.sourceRow === editingRow.sourceRow ? { ...row, url: draft.url, attributes: nextAttributes, provenance: { ...nextProvenance, url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') }, tagSources: nextTagSources, importStatus: row.importStatus === 'created' ? 'created' : 'pending', importErrors: undefined } : row) try { const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', { rows: nextRows .filter (row => row.importStatus !== 'created') .map (row => ({ sourceRow: row.sourceRow, url: row.url, attributes: row.attributes, provenance: row.provenance, tagSources: row.tagSources, metadataUrl: row.metadataUrl })), changed_row: urlChanged ? editingRow.sourceRow : -1 }) updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows)) setSearchParams ({ }) return true } catch { toast ({ title: '行の再検証に失敗しました' }) return false } finally { setSavingRow (null) } } const submit = async () => { if (sessionId == null || session == null || processable.length === 0) return setLoading (true) try { const result = await apiPost<{ created: number skipped: number failed: number rows: PostImportResultRow[] }> ('/posts/import', { rows: processable.map (row => ({ sourceRow: row.sourceRow, url: row.url, attributes: row.attributes, provenance: row.provenance, tagSources: row.tagSources, metadataUrl: row.metadataUrl })) }) const nextRows = mergeImportResults (session.rows, result.rows) const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const } setSession (nextSession) const saved = savePostImportSession (sessionId, nextSession, message => toast ({ title: '取込状態を保存できませんでした', description: message })) if (!(saved)) return navigate (`/posts/import/${ sessionId }/result`) } catch { toast ({ title: '登録に失敗しました' }) } finally { setLoading (false) } } if (!(editable)) return if (missing || sessionId == null || session == null) { return (
投稿インポート
取込状態が見つかりません.
) } return ( <> {`投稿インポート確認 | ${ SITE_TITLE }`}
投稿情報の確認・編輯
{reviewRows.map (row => (
{ setSearchParams ({ edit: String (row.sourceRow) }) }}/>
))}
navigate ('/posts/import')} onSubmit={submit}/> { if (!(open)) setSearchParams ({ }) }} onSave={saveDraft}/> ) } const PostImportFooter = ( { loading, invalidCount, processableCount, creatableCount, skipPlannedCount, onBack, onSubmit }: { loading: boolean invalidCount: number processableCount: number creatableCount: number skipPlannedCount: number onBack: () => void onSubmit: () => void }, ) => (
処理対象 {processableCount} 件 登録対象 {creatableCount} 件 スキップ予定 {skipPlannedCount} 件 要修正 {invalidCount} 件
) const SummaryChip = ({ label, value }: { label: string value: number }) => (
{label} {value}
) export default PostImportReviewPage