import { AnimatePresence, motion } from 'framer-motion' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { ChevronRight } from 'lucide-react' import { useLocation, useNavigate } from 'react-router-dom' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import PrefetchLink from '@/components/PrefetchLink' import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' import PostImportRowForm from '@/components/posts/import/PostImportRowForm' import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiGet, apiPost, isApiError } from '@/lib/api' import useDialogue from '@/lib/dialogues/useDialogue' import { parsePostNewReviewUrls } from '@/lib/postNewQueryState' import { validateImportSource } from '@/lib/postImportSourceValidation' import { applyThumbnailWarning, buildNextEditedRow, canEditReviewRow, canRetryResultRow, compactMessageRecord, hasErrorMessages, hasThumbnailBaseValue, isCompletedReviewRow, isExistingSkipRow, isManualSkipRow, isNonRecoverableFailedRow, mergeImportResults, processableImportRows, replaceImportRow, resultRepairMode, resultRowMessages, reviewSummaryCounts, retryImportRow, } from '@/lib/postImportRows' import { clearPostImportSourceDraft } from '@/lib/postImportStorage' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard' import { cn } from '@/lib/utils' import { canEditContent } from '@/lib/users' import Forbidden from '@/pages/Forbidden' import type { FC } from 'react' import type { PostImportDisplayTag, PostImportEditableDraft, PostImportResetSnapshot, PostImportResultRow, PostImportRow, } from '@/lib/postImportTypes' import type { User } from '@/types' type Props = { user: User | null } type PostMetadataResponse = { url: string title?: string thumbnailBase?: string originalCreatedFrom?: string originalCreatedBefore?: string parentPostIds?: string duration?: string videoMs?: number tags?: string displayTags?: PostImportDisplayTag[] fieldWarnings?: Record baseWarnings?: string[] validationErrors?: Record existingPostId?: number existingPost?: { id: number title: string url: string thumbnail?: string | null thumbnailBase?: string | null } | null } type BulkApiRow = { status: 'created' | 'skipped' | 'failed' post?: { id: number } existingPostId?: number existingPost?: { id: number title: string url: string thumbnail?: string | null thumbnailBase?: string | null } | null fieldWarnings?: Record baseWarnings?: string[] errors?: Record baseErrors?: string[] recoverable?: boolean } type ExistingSkippedRowsProps = { id: string rows: PostImportRow[] } const DUPLICATE_URL_MESSAGE = 'URL が重複しています.' const BULK_PROCESSING_FAILED_MESSAGE = '登録中にエラーが発生しました.' const emptyAttributes = () => ({ title: '', thumbnailBase: '', originalCreatedFrom: '', originalCreatedBefore: '', duration: '', tags: '', parentPostIds: '' }) const emptyProvenance = () => ({ url: 'manual', title: 'automatic', thumbnailBase: 'automatic', originalCreatedFrom: 'automatic', originalCreatedBefore: 'automatic', duration: 'automatic', tags: 'automatic', parentPostIds: 'automatic' }) as const const emptyTagSources = () => ({ automatic: '', manual: '' }) const cloneDisplayTags = ( tags: PostImportDisplayTag[] | undefined, ): PostImportDisplayTag[] => tags?.map (tag => ({ name: tag.name, category: tag.category, sectionLiterals: tag.sectionLiterals == null ? undefined : [...tag.sectionLiterals] })) ?? [] const cloneMessageRecord = ( record: Record, ): Record => Object.fromEntries ( Object.entries (record).map (([key, messages]) => [key, [...messages]])) const buildEmptyResetSnapshot = (url: string): PostImportResetSnapshot => ({ url, attributes: emptyAttributes (), displayTags: [], provenance: emptyProvenance (), tagSources: emptyTagSources (), fieldWarnings: { }, baseWarnings: [] }) const buildInitialRows = (urls: string[]): PostImportRow[] => { const source = urls.join ('\n') const issues = validateImportSource (source) const issuesByRow = issues.reduce> ((result, issue) => { result[issue.sourceRow] = [...(result[issue.sourceRow] ?? []), issue.message] return result }, { }) return applyDuplicateUrlErrors ( urls.map ((url, index) => { const sourceRow = index + 1 const rowIssues = issuesByRow[sourceRow] ?? [] const validationErrors: Record = rowIssues.length > 0 ? { url: [...rowIssues] } : { } return { sourceRow, url, attributes: emptyAttributes (), fieldWarnings: { }, baseWarnings: [], validationErrors, provenance: emptyProvenance (), tagSources: emptyTagSources (), status: rowIssues.length > 0 ? 'error' : 'pending', displayTags: [], resetSnapshot: buildEmptyResetSnapshot (url) } })) } const rowMetadataPending = (row: PostImportRow): boolean => row.status === 'pending' const rowOperationBusy = ( row: PostImportRow, submitting: boolean, busyRowIds: Set, ): boolean => submitting || busyRowIds.has (row.sourceRow) || rowMetadataPending (row) const rowSkipBusy = ( row: PostImportRow, submitting: boolean, busyRowIds: Set, ): boolean => submitting || busyRowIds.has (row.sourceRow) || (rowMetadataPending (row) && !(isManualSkipRow (row))) const shouldFetchMetadata = (row: PostImportRow): boolean => row.status === 'pending' && row.skipReason !== 'manual' && row.importStatus !== 'created' && row.importStatus !== 'skipped' && !(isNonRecoverableFailedRow (row)) && row.metadataUrl == null const shouldHydrateExistingPost = (row: PostImportRow): boolean => row.skipReason === 'existing' && row.existingPostId != null && row.existingPost == null && row.importStatus !== 'created' && row.importStatus !== 'skipped' const shouldFetchReviewRow = (row: PostImportRow): boolean => shouldFetchMetadata (row) || shouldHydrateExistingPost (row) const applyDuplicateUrlErrors = ( rows: PostImportRow[], ): PostImportRow[] => { const counts = rows.reduce> ((result, row) => { if (row.url !== '' && !(isManualSkipRow (row)) && row.importStatus !== 'created' && row.importStatus !== 'skipped') result[row.url] = (result[row.url] ?? 0) + 1 return result }, { }) return rows.map (row => { const urlErrors = (row.validationErrors.url ?? []).filter ( message => message !== DUPLICATE_URL_MESSAGE) if ((counts[row.url] ?? 0) < 2) { const nextValidationErrors = compactMessageRecord ({ ...row.validationErrors, url: urlErrors }) const hasErrors = hasErrorMessages (nextValidationErrors) return urlErrors.length === (row.validationErrors.url ?? []).length ? row : { ...row, validationErrors: nextValidationErrors, skipReason: row.skipReason === 'manual' ? 'manual' : row.existingPostId != null ? 'existing' : undefined, status: hasErrors ? 'error' : row.status } } return { ...row, skipReason: row.skipReason === 'manual' ? 'manual' : undefined, validationErrors: compactMessageRecord ({ ...row.validationErrors, url: [...new Set ([...urlErrors, DUPLICATE_URL_MESSAGE])] }), status: 'error' } }) } const buildDryRunFormData = (row: PostImportRow): FormData => { const formData = new FormData () formData.append ('url', row.url) formData.append ('title', String (row.attributes.title ?? '')) formData.append ('thumbnail_base', String (row.attributes.thumbnailBase ?? '')) formData.append ('tags', String (row.attributes.tags ?? '')) formData.append ('parent_post_ids', String (row.attributes.parentPostIds ?? '')) formData.append ( 'original_created_from', String (row.attributes.originalCreatedFrom ?? '')) formData.append ( 'original_created_before', String (row.attributes.originalCreatedBefore ?? '')) formData.append ('duration', String (row.attributes.duration ?? '')) if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null) formData.append ('thumbnail', row.thumbnailFile) return formData } const mergeDryRunRow = ( currentRow: PostImportRow, result: PostMetadataResponse, ): PostImportRow => { const fieldWarnings = compactMessageRecord (result.fieldWarnings ?? { }) const hasWarnings = Object.values (fieldWarnings).some (messages => messages.length > 0) || (result.baseWarnings?.length ?? 0) > 0 return applyThumbnailWarning ({ ...currentRow, url: result.url, attributes: { ...currentRow.attributes, title: result.title ?? '', thumbnailBase: result.thumbnailBase ?? '', originalCreatedFrom: result.originalCreatedFrom ?? '', originalCreatedBefore: result.originalCreatedBefore ?? '', duration: result.duration ?? '', tags: result.tags ?? '', parentPostIds: String ( result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') }, displayTags: cloneDisplayTags (result.displayTags), fieldWarnings, baseWarnings: result.baseWarnings ?? [], validationErrors: { }, importErrors: undefined, status: hasWarnings ? 'warning' : 'ready', skipReason: result.existingPostId != null || result.existingPost?.id != null ? 'existing' : undefined, existingPostId: result.existingPostId ?? result.existingPost?.id, existingPost: result.existingPost ?? undefined, importStatus: currentRow.importStatus === 'created' ? 'created' : 'pending', recoverable: undefined }) } const buildPreviewResetSnapshot = ( preview: PostMetadataResponse, ): PostImportRow['resetSnapshot'] => ({ url: preview.url, attributes: { title: preview.title ?? '', thumbnailBase: preview.thumbnailBase ?? '', originalCreatedFrom: preview.originalCreatedFrom ?? '', originalCreatedBefore: preview.originalCreatedBefore ?? '', duration: preview.duration ?? '', tags: preview.tags ?? '', parentPostIds: String (preview.parentPostIds ?? '') }, displayTags: cloneDisplayTags (preview.displayTags), provenance: emptyProvenance (), tagSources: { automatic: preview.tags ?? '', manual: '' }, fieldWarnings: preview.fieldWarnings ?? { }, baseWarnings: preview.baseWarnings ?? [], metadataUrl: preview.url }) const indexedBulkResults = ( rows: PostImportRow[], results: BulkApiRow[], ): PostImportResultRow[] => rows.map ((row, index) => { const result = results[index] const errors = result?.baseErrors?.length ? { ...(result.errors ?? { }), base: [...new Set ([...(result.errors?.base ?? []), ...result.baseErrors])] } : result?.errors if (result?.status === 'created' && result.post != null) { return { sourceRow: row.sourceRow, status: 'created', post: result.post, fieldWarnings: result.fieldWarnings, baseWarnings: result.baseWarnings, errors } } if (result?.status === 'skipped') { const existingPostId = result.existingPostId ?? result.existingPost?.id ?? row.existingPostId if (existingPostId == null) { return { sourceRow: row.sourceRow, status: 'failed', fieldWarnings: result.fieldWarnings, baseWarnings: result.baseWarnings, errors: { ...(errors ?? { }), base: [...new Set ([...(errors?.base ?? []), BULK_PROCESSING_FAILED_MESSAGE])] }, recoverable: false } } return { sourceRow: row.sourceRow, status: 'skipped', existingPostId, existingPost: result.existingPost ?? row.existingPost, fieldWarnings: result.fieldWarnings, baseWarnings: result.baseWarnings, errors } } return { sourceRow: row.sourceRow, status: 'failed', fieldWarnings: result?.fieldWarnings, baseWarnings: result?.baseWarnings, errors, recoverable: result?.recoverable } }) const mergePreviewRow = ( currentRow: PostImportRow, preview: PostMetadataResponse, ): PostImportRow => { const fieldWarnings = compactMessageRecord (preview.fieldWarnings ?? { }) const baseWarnings = preview.baseWarnings ?? [] const validationErrors = compactMessageRecord (preview.validationErrors ?? { }) const metadataChanged = currentRow.metadataUrl !== preview.url const hasWarnings = Object.values (fieldWarnings).some (messages => messages.length > 0) || baseWarnings.length > 0 const hasErrors = Object.values (validationErrors).some (messages => messages.length > 0) const nextRow: PostImportRow = { ...currentRow, attributes: { ...currentRow.attributes }, provenance: { ...currentRow.provenance }, displayTags: cloneDisplayTags (currentRow.displayTags), tagSources: { automatic: currentRow.tagSources?.automatic ?? '', manual: currentRow.tagSources?.manual ?? '' }, url: preview.url, fieldWarnings, baseWarnings, validationErrors, existingPostId: preview.existingPostId ?? preview.existingPost?.id, existingPost: preview.existingPost ?? undefined, skipReason: preview.existingPostId != null || preview.existingPost?.id != null ? 'existing' : currentRow.skipReason === 'manual' ? 'manual' : undefined, metadataUrl: preview.url, resetSnapshot: metadataChanged ? buildPreviewResetSnapshot (preview) : currentRow.resetSnapshot, status: hasErrors ? 'error' : hasWarnings ? 'warning' : 'ready' } if (currentRow.provenance.title !== 'manual') nextRow.attributes.title = preview.title ?? '' if (currentRow.provenance.thumbnailBase !== 'manual') nextRow.attributes.thumbnailBase = preview.thumbnailBase ?? '' if (currentRow.provenance.originalCreatedFrom !== 'manual') nextRow.attributes.originalCreatedFrom = preview.originalCreatedFrom ?? '' if (currentRow.provenance.originalCreatedBefore !== 'manual') nextRow.attributes.originalCreatedBefore = preview.originalCreatedBefore ?? '' if (currentRow.provenance.tags !== 'manual') { nextRow.attributes.tags = preview.tags ?? '' nextRow.displayTags = cloneDisplayTags (preview.displayTags) } nextRow.tagSources!.automatic = preview.tags ?? '' if (currentRow.provenance.parentPostIds !== 'manual') nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '') if (currentRow.provenance.duration !== 'manual') nextRow.attributes.duration = preview.duration ?? '' return applyThumbnailWarning (nextRow) } const mergeExistingSkippedRow = ( currentRow: PostImportRow, preview: PostMetadataResponse, ): PostImportRow => preview.existingPostId != null || preview.existingPost?.id != null ? { ...currentRow, url: preview.url, existingPostId: preview.existingPostId ?? preview.existingPost?.id, existingPost: preview.existingPost ?? currentRow.existingPost, skipReason: 'existing', metadataUrl: preview.url } : currentRow const buildBulkFormData = (rows: PostImportRow[]): FormData => { const formData = new FormData () formData.append ( 'posts', JSON.stringify ( rows.map (row => ({ url: row.url, title: row.attributes.title ?? '', thumbnail_base: row.attributes.thumbnailBase ?? '', tags: row.attributes.tags ?? '', parent_post_ids: row.attributes.parentPostIds ?? '', original_created_from: row.attributes.originalCreatedFrom ?? '', original_created_before: row.attributes.originalCreatedBefore ?? '', duration: row.attributes.duration ?? '' })))) rows.forEach ((row, index) => { if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null) formData.append (`thumbnails[${ index }]`, row.thumbnailFile) }) return formData } const ExistingSkippedRows: FC = ({ id, rows }) => { return (
{rows.map (row => { const post = row.existingPost if (post == null) { return (
{row.url}
) } return (
{post.title}
{post.url}
) })}
) } const editableRowDirty = (row: PostImportRow): boolean => row.skipReason === 'manual' || row.url !== row.resetSnapshot.url || String (row.attributes.title ?? '') !== String (row.resetSnapshot.attributes.title ?? '') || String (row.attributes.thumbnailBase ?? '') !== String (row.resetSnapshot.attributes.thumbnailBase ?? '') || String (row.attributes.originalCreatedFrom ?? '') !== String (row.resetSnapshot.attributes.originalCreatedFrom ?? '') || String (row.attributes.originalCreatedBefore ?? '') !== String (row.resetSnapshot.attributes.originalCreatedBefore ?? '') || String (row.attributes.duration ?? '') !== String (row.resetSnapshot.attributes.duration ?? '') || String (row.attributes.tags ?? '') !== String (row.resetSnapshot.attributes.tags ?? '') || String (row.attributes.parentPostIds ?? '') !== String (row.resetSnapshot.attributes.parentPostIds ?? '') || row.thumbnailFile != null const PostImportReviewPage: FC = ({ user }) => { const editable = canEditContent (user) const dialogue = useDialogue () const location = useLocation () const navigate = useNavigate () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const existingRowsTransition = animationMode === 'off' ? { duration: 0 } : animationMode === 'reduced' ? { duration: .08, ease: 'linear' as const } : { duration: .2, ease: 'easeOut' as const } const [rows, setRows] = useState (null) const [metadataLoading, setMetadataLoading] = useState (false) const [submitting, setSubmitting] = useState (false) const [busyRowIds, setBusyRowIds] = useState> (new Set ()) const [editingRow, setEditingRow] = useState (null) const [showExistingRows, setShowExistingRows] = useState (false) const rowsRef = useRef ([]) const metadataSequenceRef = useRef (0) const discardChanges = useCallback (() => undefined, []) const dirty = useMemo ( () => rows?.some (row => editableRowDirty (row)) ?? false, [rows], ) const { allowNextNavigation } = useUnsavedChangesGuard ({ dirty, onDiscard: discardChanges, }) const commitRows = useCallback ((nextRows: PostImportRow[]) => { rowsRef.current = nextRows setRows (nextRows) return nextRows }, []) const updateRows = useCallback (( updater: (currentRows: PostImportRow[]) => PostImportRow[], ) => { const nextRows = applyDuplicateUrlErrors (updater (rowsRef.current)) return commitRows (nextRows) }, [commitRows]) const currentRowBySource = useCallback (( sourceRow: number, ): PostImportRow | null => rowsRef.current.find (row => row.sourceRow === sourceRow) ?? null, []) const finishImport = useCallback (() => { allowNextNavigation () clearPostImportSourceDraft (message => toast ({ title: '入力内容を削除できませんでした', description: message })) navigate ('/posts') }, [allowNextNavigation, navigate]) useEffect (() => { const urls = parsePostNewReviewUrls (location.search) const initialRows = buildInitialRows (urls) commitRows (initialRows) let active = true const sequence = ++metadataSequenceRef.current const controllers = new Set () let nextIndex = 0 const targetSourceRows = initialRows .filter (row => shouldFetchReviewRow (row)) .map (row => row.sourceRow) setMetadataLoading (targetSourceRows.length > 0) const mergeCurrentRow = ( sourceRow: number, updater: (row: PostImportRow) => PostImportRow, ) => { updateRows (currentRows => currentRows.map (row => row.sourceRow === sourceRow ? updater (row) : row)) } const worker = async () => { while (active) { const sourceRow = targetSourceRows[nextIndex] ++nextIndex if (sourceRow == null) return const currentRow = currentRowBySource (sourceRow) if (currentRow == null || !(shouldFetchReviewRow (currentRow))) continue const controller = new AbortController () controllers.add (controller) try { const preview = await apiGet ('/posts/metadata', { params: { url: currentRow.url }, signal: controller.signal }) if (!(active) || metadataSequenceRef.current !== sequence) return mergeCurrentRow (sourceRow, row => row.importStatus === 'created' || row.importStatus === 'skipped' || isNonRecoverableFailedRow (row) || row.skipReason === 'manual' ? row : (shouldFetchMetadata (row) ? mergePreviewRow (row, preview) : mergeExistingSkippedRow (row, preview))) } catch (requestError) { if (controller.signal.aborted) return if (!(active) || metadataSequenceRef.current !== sequence) return if (!(isApiError<{ errors?: Record baseErrors?: string[] }> (requestError))) { mergeCurrentRow (sourceRow, row => ({ ...row, status: 'error' })) continue } if (requestError.response?.status === 422) { const rowErrors = compactMessageRecord ({ ...(requestError.response.data.errors ?? { }), ...(requestError.response.data.baseErrors?.length ? { base: requestError.response.data.baseErrors } : { }) }) mergeCurrentRow (sourceRow, row => ({ ...row, validationErrors: rowErrors, status: 'error' })) } else { mergeCurrentRow (sourceRow, row => ({ ...row, status: 'error' })) } } finally { controllers.delete (controller) } } } void Promise.all ( Array.from ( { length: Math.min (4, targetSourceRows.length) }, () => worker (), )).finally (() => { if (active && metadataSequenceRef.current === sequence) setMetadataLoading (false) }) return () => { active = false controllers.forEach (controller => controller.abort ()) } }, [commitRows, currentRowBySource, location.search, updateRows]) useEffect (() => { if (editingRow == null || resultRepairMode (rowsRef.current) !== 'failed') return const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`) element?.scrollIntoView ({ block: 'center', behavior: 'smooth' }) }, [editingRow, rows]) const reviewRowsState = rows ?? [] const counts = useMemo ( () => reviewSummaryCounts (reviewRowsState), [reviewRowsState], ) const sortedRows = resultRepairMode (reviewRowsState) === 'failed' ? [...reviewRowsState].sort ((a, b) => { const aRepair = a.recoverable === true && (a.importStatus === 'failed' || (a.importStatus === 'pending' && hasErrorMessages (a.validationErrors))) ? 0 : 1 const bRepair = b.recoverable === true && (b.importStatus === 'failed' || (b.importStatus === 'pending' && hasErrorMessages (b.validationErrors))) ? 0 : 1 return aRepair - bRepair || a.sourceRow - b.sourceRow }) : reviewRowsState const existingRows = sortedRows.filter (row => isExistingSkipRow (row)) const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row))) const processingRows = processableImportRows (reviewRowsState) const canSubmit = processingRows.length > 0 const toggleManualSkip = async (sourceRow: number, checked: boolean) => { const currentRow = currentRowBySource (sourceRow) if (currentRow == null) return if (rowSkipBusy (currentRow, submitting, busyRowIds)) return updateRows (currentRows => currentRows.map (row => { if (row.sourceRow !== sourceRow) return row if (isExistingSkipRow (row) || row.importStatus === 'created') return row return { ...row, skipReason: checked ? 'manual' : row.existingPostId != null ? 'existing' : undefined } })) } const saveDraft = async ( row: PostImportRow, { draft, resetRequested }: { draft: PostImportEditableDraft resetRequested: boolean }, ): Promise<{ saved: boolean row: PostImportRow | null }> => { const currentRow = currentRowBySource (row.sourceRow) if (currentRow == null) return { saved: false, row: null } if (!(canEditReviewRow (currentRow))) return { saved: false, row: null } const baseRow = resetRequested ? { ...currentRow, url: currentRow.resetSnapshot.url, attributes: { ...currentRow.resetSnapshot.attributes }, displayTags: cloneDisplayTags (currentRow.resetSnapshot.displayTags), provenance: { ...currentRow.resetSnapshot.provenance }, tagSources: { ...currentRow.resetSnapshot.tagSources }, fieldWarnings: cloneMessageRecord (currentRow.resetSnapshot.fieldWarnings), baseWarnings: [...currentRow.resetSnapshot.baseWarnings], metadataUrl: currentRow.resetSnapshot.metadataUrl, thumbnailFile: undefined } : currentRow const urlChanged = draft.url !== baseRow.url const nextRow = buildNextEditedRow (baseRow, draft, urlChanged) let candidateRow = nextRow try { candidateRow = urlChanged ? mergePreviewRow ( nextRow, await apiGet ('/posts/metadata', { params: { url: nextRow.url } })) : nextRow const dryRun = await apiPost ( '/posts?dry=1', buildDryRunFormData (candidateRow)) const mergedRow = mergeDryRunRow (candidateRow, dryRun) const mergedRows = updateRows (currentRows => replaceImportRow (currentRows, mergedRow)) const mergedTarget = mergedRows.find ( current => current.sourceRow === baseRow.sourceRow) if (mergedTarget == null) return { saved: false, row: null } if (hasErrorMessages (mergedTarget.validationErrors)) return { saved: false, row: mergedTarget } return { saved: true, row: null } } catch (requestError) { if (isApiError<{ errors?: Record baseErrors?: string[] }> (requestError) && requestError.response?.status === 422) { const errorRow = { ...candidateRow, validationErrors: compactMessageRecord ({ ...(requestError.response.data.errors ?? { }), ...(requestError.response.data.baseErrors?.length ? { base: requestError.response.data.baseErrors } : { }) }), baseWarnings: [], fieldWarnings: { }, status: 'error' as const } return { saved: false, row: applyThumbnailWarning (errorRow) } } toast ({ title: '行の再検証に失敗しました' }) return { saved: false, row: null } } } const openEditingDialogue = async (row: PostImportRow) => { const saveRowDraft = ( { draft, resetRequested }: { draft: PostImportEditableDraft resetRequested: boolean }, ) => saveDraft (row, { draft, resetRequested }) await dialogue.form ({ title: '投稿を編輯', cancelText: '取消', size: 'large', body: controls => ( ) }) } const editRow = async (row: PostImportRow) => { const currentRow = currentRowBySource (row.sourceRow) if (currentRow == null) return if (!(canEditReviewRow (currentRow))) return if (rowOperationBusy (currentRow, submitting, busyRowIds)) return setEditingRow (currentRow) try { await openEditingDialogue (currentRow) } finally { setEditingRow (current => current?.sourceRow === currentRow.sourceRow ? null : current) } } const retry = async (sourceRow: number) => { const originalRow = currentRowBySource (sourceRow) if (originalRow == null) return if (rowOperationBusy (originalRow, submitting, busyRowIds)) return setBusyRowIds (current => new Set ([...current, sourceRow])) try { updateRows (currentRows => retryImportRow (currentRows, sourceRow)) const target = currentRowBySource (sourceRow) if (target == null) return const result = await apiPost<{ results: BulkApiRow[] }>( '/posts/bulk', buildBulkFormData ([target])) if (result.results.length !== 1) { updateRows (currentRows => replaceImportRow (currentRows, originalRow)) toast ({ title: '登録結果が不完全でした' }) return } const indexedResults = indexedBulkResults ([target], result.results) const nextRows = applyDuplicateUrlErrors ( mergeImportResults (rowsRef.current, indexedResults)) .map (row => applyThumbnailWarning (row)) commitRows (nextRows) if (nextRows.every (row => isCompletedReviewRow (row))) finishImport () } catch { updateRows (currentRows => replaceImportRow (currentRows, originalRow)) toast ({ title: '再試行に失敗しました' }) } finally { setBusyRowIds (current => { const next = new Set (current) next.delete (sourceRow) return next }) } } const submit = async () => { if (metadataLoading || submitting || busyRowIds.size > 0) return const latestProcessingRows = processableImportRows (rowsRef.current) if (latestProcessingRows.length === 0) return setSubmitting (true) try { const result = await apiPost<{ results: BulkApiRow[] }>( '/posts/bulk', buildBulkFormData (latestProcessingRows)) if (result.results.length !== latestProcessingRows.length) { toast ({ title: '登録結果が不完全でした' }) return } const indexedResults = indexedBulkResults (latestProcessingRows, result.results) const nextRows = applyDuplicateUrlErrors ( mergeImportResults (rowsRef.current, indexedResults)) .map (row => applyThumbnailWarning (row)) commitRows (nextRows) if (nextRows.every (row => isCompletedReviewRow (row))) finishImport () } catch { toast ({ title: '登録に失敗しました' }) } finally { setSubmitting (false) } } if (!(editable)) return if (rows == null) return null return ( <> {`追加内容確認 | ${ SITE_TITLE }`}
追加内容確認 {existingRows.length > 0 && (
{showExistingRows && ( )}
)}
{reviewRows.map ((row, index) => (
editRow (row)} onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)} onRetry={canRetryResultRow (row) ? () => retry (row.sourceRow) : undefined} rowMessages={resultRowMessages (row)}/>
))}
0} canSubmit={canSubmit} creatableCount={counts.creatable} manualSkippedCount={counts.manualSkipped} existingSkippedCount={counts.existingSkipped} pendingOrErrorCount={counts.pendingOrError} onBack={() => navigate ('/posts/new')} onSubmit={() => submit ()}/> ) } const PostImportFooter = ( { metadataLoading, submitting, busyRowsPresent, canSubmit, creatableCount, manualSkippedCount, existingSkippedCount, pendingOrErrorCount, onBack, onSubmit }: { metadataLoading: boolean submitting: boolean busyRowsPresent: boolean canSubmit: boolean creatableCount: number manualSkippedCount: number existingSkippedCount: number pendingOrErrorCount: number onBack: () => void onSubmit: () => void }, ) => (
作成対象 {creatableCount}件 手動スキップ {manualSkippedCount}件 既存投稿による自動スキップ {existingSkippedCount}件 未処理 {pendingOrErrorCount}件
) export default PostImportReviewPage