From ead825e3ee1c8f45f8b74af881a334428eaea998 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 16 Jul 2026 07:56:45 +0900 Subject: [PATCH] #399 --- .../src/pages/posts/PostImportResultPage.tsx | 169 +++++++++++++++++- .../src/pages/posts/PostImportReviewPage.tsx | 94 +++++----- 2 files changed, 213 insertions(+), 50 deletions(-) diff --git a/frontend/src/pages/posts/PostImportResultPage.tsx b/frontend/src/pages/posts/PostImportResultPage.tsx index da1b484..c53d6de 100644 --- a/frontend/src/pages/posts/PostImportResultPage.tsx +++ b/frontend/src/pages/posts/PostImportResultPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useNavigate, useParams } from 'react-router-dom' @@ -6,12 +6,14 @@ import FieldError from '@/components/common/FieldError' import PageTitle from '@/components/common/PageTitle' import PrefetchLink from '@/components/PrefetchLink' import MainArea from '@/components/layout/MainArea' +import PostImportRowForm from '@/components/posts/import/PostImportRowForm' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPost } from '@/lib/api' +import useDialogue from '@/lib/dialogues/useDialogue' import { canEditContent } from '@/lib/users' import { clearPostImportSourceDraft, initialisePreviewRows, @@ -25,6 +27,7 @@ import Forbidden from '@/pages/Forbidden' import type { FC } from 'react' +import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm' import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession' import type { PostImportSession } from '@/lib/postImportSession' import type { User } from '@/types' @@ -34,15 +37,66 @@ type Props = { user: User | null } const rowMessages = (row: PostImportRow): string[] => Object.values (row.importErrors ?? { }).flat () +const buildNextEditedRow = ( + editingRow: PostImportRow, + draft: PostImportRowDraft, + urlChanged: boolean, +): PostImportRow => { + 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 ?? '' + } + + return { + ...editingRow, + url: draft.url, + attributes: nextAttributes, + provenance: { + ...nextProvenance, + url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') }, + tagSources: nextTagSources, + importStatus: editingRow.importStatus === 'created' ? 'created' : 'pending', + importErrors: undefined } +} + const PostImportResultPage: FC = ({ user }) => { const editable = canEditContent (user) + const dialogue = useDialogue () const navigate = useNavigate () const { sessionId } = useParams () const [session, setSession] = useState (null) const [missing, setMissing] = useState (false) const [loadingRow, setLoadingRow] = useState (null) + const [, setEditingRow] = useState (null) + const sessionRef = useRef (null) useEffect (() => { if (sessionId == null) @@ -62,10 +116,111 @@ const PostImportResultPage: FC = ({ user }) => { toast ({ title: '取込状態を保存できませんでした', description: message })) }, [session, sessionId]) + useEffect (() => { + sessionRef.current = session + }, [session]) + const counts = useMemo ( () => resultSummaryCounts (session?.rows ?? []), [session]) + const updateSessionRows = (nextRows: PostImportRow[]) => + setSession (current => + current != null ? { ...current, rows: nextRows } : current) + + const saveDraft = async ( + row: PostImportRow, + { draft, resetRequested }: { + draft: PostImportRowDraft + resetRequested: boolean }, + ): Promise<{ saved: boolean + row: PostImportRow | null }> => { + const currentSession = sessionRef.current + if (currentSession == null) + return { saved: false, row: null } + + const baseRow = + resetRequested + ? { ...row, + url: row.resetSnapshot.url, + attributes: { ...row.resetSnapshot.attributes }, + provenance: { ...row.resetSnapshot.provenance }, + tagSources: { ...row.resetSnapshot.tagSources }, + fieldWarnings: Object.fromEntries ( + Object.entries (row.resetSnapshot.fieldWarnings) + .map (([key, values]) => [key, [...values]])), + baseWarnings: [...row.resetSnapshot.baseWarnings], + metadataUrl: row.resetSnapshot.metadataUrl } + : row + const urlChanged = draft.url !== baseRow.url + const nextRow = buildNextEditedRow (baseRow, draft, urlChanged) + + const nextRows = currentSession.rows.map (row => + row.sourceRow === baseRow.sourceRow + ? nextRow + : 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 ? baseRow.sourceRow : -1 }) + const validatedRows = initialisePreviewRows (validated.rows) + const target = validatedRows.find (_1 => _1.sourceRow === baseRow.sourceRow) + if (target != null && Object.keys (target.validationErrors).length > 0) + return { saved: false, row: target } + updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) + return { saved: true, row: null } + } + catch + { + toast ({ title: '行の再検証に失敗しました' }) + return { saved: false, row: null } + } + } + + const openEditingDialogue = async (row: PostImportRow) => { + const saveRowDraft = ( + { draft, resetRequested }: { + draft: PostImportRowDraft + resetRequested: boolean }, + ) => + saveDraft (row, { draft, resetRequested }) + + await dialogue.form ({ + title: '投稿を編輯', + description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`, + cancelText: '取消', + size: 'large', + body: controls => ( + ) }) + } + + const editRow = async (row: PostImportRow) => { + setEditingRow (row) + try + { + await openEditingDialogue (row) + } + finally + { + setEditingRow (current => + current?.sourceRow === row.sourceRow + ? null + : current) + } + } + const retry = async (sourceRow: number) => { if (session == null || sessionId == null) return @@ -93,6 +248,7 @@ const PostImportResultPage: FC = ({ user }) => { ...session, rows: validatedRows, repairMode: 'failed' as const } + sessionRef.current = nextSession setSession (nextSession) const target = validatedRows.find (_1 => _1.sourceRow === sourceRow) if (target == null) @@ -102,7 +258,7 @@ const PostImportResultPage: FC = ({ user }) => { const saved = savePostImportSession (sessionId, nextSession, message => toast ({ title: '取込状態を保存できませんでした', description: message })) if (saved) - navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`) + void editRow (target) return } @@ -138,6 +294,7 @@ const PostImportResultPage: FC = ({ user }) => { ...nextSession, rows: nextRows, repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const } + sessionRef.current = resultSession setSession (resultSession) if (recoverableTarget != null && Object.keys (recoverableTarget.validationErrors).length > 0) @@ -145,12 +302,13 @@ const PostImportResultPage: FC = ({ user }) => { const saved = savePostImportSession (sessionId, resultSession, message => toast ({ title: '取込状態を保存できませんでした', description: message })) if (saved) - navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`) + void editRow (recoverableTarget) return } } catch { + sessionRef.current = session setSession (session) toast ({ title: '再試行に失敗しました' }) } @@ -165,12 +323,15 @@ const PostImportResultPage: FC = ({ user }) => { return const nextSession = { ...session, repairMode: 'failed' as const } + sessionRef.current = nextSession setSession (nextSession) const saved = savePostImportSession (sessionId, nextSession, message => toast ({ title: '取込状態を保存できませんでした', description: message })) if (!(saved)) return - navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`) + const row = nextSession.rows.find (_1 => _1.sourceRow === sourceRow) + if (row != null) + void editRow (row) } if (!(editable)) diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 207694c..08de705 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { useNavigate, useParams } from 'react-router-dom' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' @@ -38,11 +38,12 @@ const PostImportReviewPage: FC = ({ user }) => { const dialogue = useDialogue () 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 [editingRow, setEditingRow] = useState (null) + const sessionRef = useRef (null) useEffect (() => { if (sessionId == null) @@ -62,12 +63,11 @@ const PostImportReviewPage: FC = ({ user }) => { toast ({ title: '取込状態を保存できませんでした', description: message })) }, [session, sessionId]) + useEffect (() => { + sessionRef.current = session + }, [session]) + 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), @@ -98,36 +98,34 @@ const PostImportReviewPage: FC = ({ user }) => { current != null ? { ...current, rows: nextRows } : current) const saveDraft = async ( - { draft, resetRequested, resetSnapshot }: { + row: PostImportRow, + { draft, resetRequested }: { draft: PostImportRowDraft - resetRequested: boolean - resetSnapshot: PostImportRow['resetSnapshot'] }, + resetRequested: boolean }, ): Promise<{ saved: boolean row: PostImportRow | null }> => { - if (session == null) - return { saved: false, row: null } - - if (editingRow == null) + const currentSession = sessionRef.current + if (currentSession == null) return { saved: false, row: null } const baseRow = resetRequested - ? { ...editingRow, - url: resetSnapshot.url, - attributes: { ...resetSnapshot.attributes }, - provenance: { ...resetSnapshot.provenance }, - tagSources: { ...resetSnapshot.tagSources }, + ? { ...row, + url: row.resetSnapshot.url, + attributes: { ...row.resetSnapshot.attributes }, + provenance: { ...row.resetSnapshot.provenance }, + tagSources: { ...row.resetSnapshot.tagSources }, fieldWarnings: Object.fromEntries ( - Object.entries (resetSnapshot.fieldWarnings) + Object.entries (row.resetSnapshot.fieldWarnings) .map (([key, values]) => [key, [...values]])), - baseWarnings: [...resetSnapshot.baseWarnings], - metadataUrl: resetSnapshot.metadataUrl } - : editingRow + baseWarnings: [...row.resetSnapshot.baseWarnings], + metadataUrl: row.resetSnapshot.metadataUrl } + : row const urlChanged = draft.url !== baseRow.url const nextRow = buildNextEditedRow (baseRow, draft, urlChanged) - const nextRows = session.rows.map (row => - row.sourceRow === editingRow.sourceRow + const nextRows = currentSession.rows.map (row => + row.sourceRow === baseRow.sourceRow ? nextRow : row) try @@ -142,13 +140,12 @@ const PostImportReviewPage: FC = ({ user }) => { provenance: row.provenance, tagSources: row.tagSources, metadataUrl: row.metadataUrl })), - changed_row: urlChanged ? editingRow.sourceRow : -1 }) + changed_row: urlChanged ? baseRow.sourceRow : -1 }) const validatedRows = initialisePreviewRows (validated.rows) - const target = validatedRows.find (row => row.sourceRow === editingRow.sourceRow) + const target = validatedRows.find (row => row.sourceRow === baseRow.sourceRow) if (target != null && Object.keys (target.validationErrors).length > 0) return { saved: false, row: target } updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows)) - setSearchParams ({ }) return { saved: true, row: null } } catch @@ -164,10 +161,7 @@ const PostImportReviewPage: FC = ({ user }) => { draft: PostImportRowDraft resetRequested: boolean }, ) => - saveDraft ({ - draft, - resetRequested, - resetSnapshot: row.resetSnapshot }) + saveDraft (row, { draft, resetRequested }) await dialogue.form ({ title: '投稿を編輯', @@ -179,15 +173,22 @@ const PostImportReviewPage: FC = ({ user }) => { row={row} controls={controls} onSave={saveRowDraft}/>) }) - setSearchParams ({ }) } - useEffect (() => { - if (editingRow == null) - return - - void openEditingDialogue (editingRow) - }, [editingRow]) + const editRow = async (row: PostImportRow) => { + setEditingRow (row) + try + { + await openEditingDialogue (row) + } + finally + { + setEditingRow (current => + current?.sourceRow === row.sourceRow + ? null + : current) + } + } const submit = async () => { if (sessionId == null || session == null || processable.length === 0) @@ -212,8 +213,10 @@ const PostImportReviewPage: FC = ({ user }) => { const firstInvalid = mergedRows.find (row => Object.keys (row.validationErrors).length > 0) if (firstInvalid != null) { - setSession ({ ...session, rows: mergedRows }) - setSearchParams ({ edit: String (firstInvalid.sourceRow) }) + const nextSession = { ...session, rows: mergedRows } + sessionRef.current = nextSession + setSession (nextSession) + void editRow (firstInvalid) return } @@ -252,11 +255,12 @@ const PostImportReviewPage: FC = ({ user }) => { const repairSession = { ...session, rows: nextRows, repairMode: 'failed' as const } + sessionRef.current = repairSession setSession (repairSession) const saved = savePostImportSession (sessionId, repairSession, message => toast ({ title: '取込状態を保存できませんでした', description: message })) if (saved) - setSearchParams ({ edit: String (firstRecoverable.sourceRow) }) + void editRow (firstRecoverable) return } const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const } @@ -309,9 +313,7 @@ const PostImportReviewPage: FC = ({ user }) => {
{ - setSearchParams ({ edit: String (row.sourceRow) }) - }}/> + onEdit={() => void editRow (row)}/>
))}