ファイル
btrc-hub/frontend/src/pages/posts/PostImportReviewPage.tsx
T
みてるぞ f1181e8510 広場投稿追加画面の刷新 (#399) (#413)
Reviewed-on: #413
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-19 00:03:10 +09:00

1190 行
36 KiB
TypeScript

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<string, string[]>
baseWarnings?: string[]
validationErrors?: Record<string, string[]>
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<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]>
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<string, string[]>,
): Record<string, string[]> =>
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<Record<number, string[]>> ((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<string, string[]> =
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<number>,
): boolean =>
submitting
|| busyRowIds.has (row.sourceRow)
|| rowMetadataPending (row)
const rowSkipBusy = (
row: PostImportRow,
submitting: boolean,
busyRowIds: Set<number>,
): 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<Record<string, number>> ((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<ExistingSkippedRowsProps> = ({ id, rows }) => {
return (
<div id={id} className="mt-3 max-h-72 overflow-y-auto rounded border">
<div className="divide-y">
{rows.map (row => {
const post = row.existingPost
if (post == null)
{
return (
<div key={row.sourceRow} className="flex items-center gap-3 p-3">
<PostThumbnailPreview
url=""
className="h-10 w-10 shrink-0"/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{row.url}
</div>
</div>
</div>)
}
return (
<PrefetchLink
key={post.id}
to={`/posts/${ post.id }`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3">
<PostThumbnailPreview
url={post.thumbnail || post.thumbnailBase || ''}
className="h-10 w-10 shrink-0"/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{post.title}
</div>
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
{post.url}
</div>
</div>
</PrefetchLink>)
})}
</div>
</div>)
}
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<Props> = ({ 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<PostImportRow[] | null> (null)
const [metadataLoading, setMetadataLoading] = useState (false)
const [submitting, setSubmitting] = useState (false)
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
const [showExistingRows, setShowExistingRows] = useState (false)
const rowsRef = useRef<PostImportRow[]> ([])
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<AbortController> ()
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<PostMetadataResponse> ('/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<string, string[]>
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<PostMetadataResponse> ('/posts/metadata', {
params: { url: nextRow.url } }))
: nextRow
const dryRun = await apiPost<PostMetadataResponse> (
'/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<string, string[]>
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 => (
<PostImportRowForm
row={row}
controls={controls}
onSave={saveRowDraft}/>) })
}
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 <Forbidden/>
if (rows == null)
return null
return (
<>
<Helmet>
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
</Helmet>
<MainArea className="pb-40">
<div className="mx-auto max-w-6xl space-y-6">
<PageTitle></PageTitle>
{existingRows.length > 0 && (
<div className="rounded-lg border p-4">
<button
type="button"
aria-expanded={showExistingRows}
aria-controls="post-import-existing-skips"
className="flex items-center gap-2 text-left text-sm font-medium"
onClick={() => setShowExistingRows (current => !(current))}>
<span className="inline-flex">
<ChevronRight
className={cn (
'h-4 w-4',
showExistingRows && 'rotate-90',
animationMode === 'off'
? ''
: 'transition-transform')}/>
</span>
稿 {counts.existingSkipped}
</button>
<AnimatePresence initial={false}>
{showExistingRows && (
<motion.div
initial={
animationMode === 'off'
? false
: { height: 0, opacity: 0 }
}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={existingRowsTransition}
className="overflow-hidden">
<ExistingSkippedRows
id="post-import-existing-skips"
rows={existingRows}/>
</motion.div>)}
</AnimatePresence>
</div>)}
<div className="space-y-3">
{reviewRows.map ((row, index) => (
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary
row={row}
displayNumber={index + 1}
showSkipToggle={true}
editDisabled={rowOperationBusy (row, submitting, busyRowIds)}
retryDisabled={rowOperationBusy (row, submitting, busyRowIds)}
skipDisabled={
rowSkipBusy (row, submitting, busyRowIds)
|| isExistingSkipRow (row)
|| row.importStatus === 'created'}
onEdit={() => editRow (row)}
onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
onRetry={canRetryResultRow (row) ? () => retry (row.sourceRow) : undefined}
rowMessages={resultRowMessages (row)}/>
</div>))}
</div>
</div>
</MainArea>
<PostImportFooter
metadataLoading={metadataLoading}
submitting={submitting}
busyRowsPresent={busyRowIds.size > 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 },
) => (
<div
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
dark:border-neutral-700 dark:bg-neutral-950/95">
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> {creatableCount}</span>
<span> {manualSkippedCount}</span>
<span>稿 {existingSkippedCount}</span>
<span> {pendingOrErrorCount}</span>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Button
type="button"
variant="outline"
className="w-full md:w-auto"
onClick={onBack}
disabled={submitting}>
</Button>
<Button
type="button"
className="w-full md:w-auto"
onClick={onSubmit}
disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
{creatableCount > 1 ? '一括追加' : '追加'}
</Button>
</div>
</div>
</div>)
export default PostImportReviewPage