868 行
27 KiB
TypeScript
868 行
27 KiB
TypeScript
import { useQueries } from '@tanstack/react-query'
|
||
import { AnimatePresence, motion } from 'framer-motion'
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Helmet } from 'react-helmet-async'
|
||
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 { fetchPost } from '@/lib/posts'
|
||
import {
|
||
appendPostNewSessionId,
|
||
parsePostNewState,
|
||
parsePostNewSessionId,
|
||
serialisePostNewState,
|
||
} from '@/lib/postNewQueryState'
|
||
import {
|
||
buildNextEditedRow,
|
||
canEditReviewRow,
|
||
canRetryResultRow,
|
||
clearPostImportSession,
|
||
clearPostImportSourceDraft,
|
||
generatePostImportSessionId,
|
||
initialisePreviewRows,
|
||
isCompletedReviewRow,
|
||
isExistingSkipRow,
|
||
isNonRecoverableFailedRow,
|
||
mergeImportResults,
|
||
processableImportRows,
|
||
replaceImportRow,
|
||
resultRepairMode,
|
||
resultRowMessages,
|
||
reviewSummaryCounts,
|
||
retryImportRow,
|
||
savePostImportSession,
|
||
loadPostImportSession,
|
||
} from '@/lib/postImportSession'
|
||
import { postsKeys } from '@/lib/queryKeys'
|
||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||
import { canEditContent } from '@/lib/users'
|
||
import Forbidden from '@/pages/Forbidden'
|
||
|
||
import type { FC } from 'react'
|
||
|
||
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
||
import type {
|
||
PostImportResultRow,
|
||
PostImportRow,
|
||
PostImportSession,
|
||
} from '@/lib/postImportSession'
|
||
import type { Post, User } from '@/types'
|
||
|
||
type Props = { user: User | null }
|
||
|
||
type PreviewResponse = {
|
||
url: string
|
||
title?: string
|
||
thumbnailBase?: string
|
||
originalCreatedFrom?: string
|
||
originalCreatedBefore?: string
|
||
duration?: string
|
||
videoMs?: number
|
||
tags?: string
|
||
fieldWarnings?: Record<string, string[]>
|
||
baseWarnings?: string[]
|
||
existingPost?: { id: number } | null }
|
||
|
||
type BulkApiRow = {
|
||
status: 'created' | 'skipped' | 'failed'
|
||
post?: { id: number }
|
||
existingPost?: { id: number } | null
|
||
fieldWarnings?: Record<string, string[]>
|
||
baseWarnings?: string[]
|
||
errors?: Record<string, string[]>
|
||
baseErrors?: string[]
|
||
recoverable?: boolean }
|
||
|
||
type ExistingSkippedRowsProps = {
|
||
open: boolean
|
||
rows: PostImportRow[] }
|
||
|
||
const isRepairRow = (row: PostImportRow): boolean =>
|
||
row.recoverable === true
|
||
&& (row.importStatus === 'failed'
|
||
|| (row.importStatus === 'pending'
|
||
&& Object.keys (row.validationErrors).length > 0))
|
||
|
||
|
||
const buildSession = (rows: PostImportRow[]): PostImportSession => ({
|
||
version: 3,
|
||
expiresAt: '',
|
||
source: rows.map (row => row.url).join ('\n'),
|
||
rows,
|
||
repairMode: resultRepairMode (rows) })
|
||
|
||
|
||
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 ('video_ms', String (row.attributes.videoMs ?? ''))
|
||
formData.append ('duration', String (row.attributes.duration ?? ''))
|
||
return formData
|
||
}
|
||
|
||
|
||
const mergeDryRunRow = (
|
||
currentRow: PostImportRow,
|
||
result: PreviewResponse,
|
||
): PostImportRow =>
|
||
initialisePreviewRows ([{
|
||
...currentRow,
|
||
url: result.url,
|
||
attributes: {
|
||
...currentRow.attributes,
|
||
title: result.title ?? '',
|
||
thumbnailBase: result.thumbnailBase ?? '',
|
||
originalCreatedFrom: result.originalCreatedFrom ?? '',
|
||
originalCreatedBefore: result.originalCreatedBefore ?? '',
|
||
duration: result.duration ?? '',
|
||
videoMs: result.videoMs ?? '',
|
||
tags: result.tags ?? '',
|
||
parentPostIds: String (currentRow.attributes.parentPostIds ?? '') },
|
||
fieldWarnings: result.fieldWarnings ?? { },
|
||
baseWarnings: result.baseWarnings ?? [],
|
||
validationErrors: { },
|
||
importErrors: undefined,
|
||
status:
|
||
Object.keys (result.fieldWarnings ?? { }).length > 0
|
||
|| (result.baseWarnings?.length ?? 0) > 0
|
||
? 'warning'
|
||
: 'ready',
|
||
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
|
||
existingPostId: result.existingPost?.id,
|
||
importStatus:
|
||
currentRow.importStatus === 'created'
|
||
? 'created'
|
||
: 'pending',
|
||
recoverable: undefined }])[0]
|
||
|
||
|
||
const indexedBulkResults = (
|
||
rows: PostImportRow[],
|
||
results: BulkApiRow[],
|
||
): PostImportResultRow[] =>
|
||
rows.map ((row, index) => {
|
||
const result = results[index]
|
||
if (result?.status === 'created' && result.post != null)
|
||
{
|
||
return {
|
||
sourceRow: row.sourceRow,
|
||
status: 'created',
|
||
post: result.post,
|
||
fieldWarnings: result.fieldWarnings,
|
||
baseWarnings: result.baseWarnings,
|
||
errors: result.errors }
|
||
}
|
||
if (result?.status === 'skipped' && result.existingPost != null)
|
||
{
|
||
return {
|
||
sourceRow: row.sourceRow,
|
||
status: 'skipped',
|
||
existingPostId: result.existingPost.id,
|
||
fieldWarnings: result.fieldWarnings,
|
||
baseWarnings: result.baseWarnings,
|
||
errors: result.errors }
|
||
}
|
||
return {
|
||
sourceRow: row.sourceRow,
|
||
status: 'failed',
|
||
fieldWarnings: result?.fieldWarnings,
|
||
baseWarnings: result?.baseWarnings,
|
||
errors: result?.errors,
|
||
recoverable: result?.recoverable }
|
||
})
|
||
|
||
|
||
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 ?? '',
|
||
video_ms: row.attributes.videoMs ?? '',
|
||
duration: row.attributes.duration ?? '' }))))
|
||
return formData
|
||
}
|
||
|
||
|
||
const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ open, rows }) => {
|
||
const existingPostIds = useMemo (
|
||
() =>
|
||
open
|
||
? Array.from (
|
||
new Set (
|
||
rows
|
||
.map (row => row.existingPostId)
|
||
.filter ((postId): postId is number => postId != null),
|
||
),
|
||
)
|
||
: [],
|
||
[open, rows],
|
||
)
|
||
const posts = useQueries ({
|
||
queries: existingPostIds.map (postId => ({
|
||
enabled: open,
|
||
queryKey: postsKeys.show (String (postId)),
|
||
queryFn: () => fetchPost (String (postId)) })),
|
||
})
|
||
const postsById = new Map<number, Post> (
|
||
posts
|
||
.map (query => query.data)
|
||
.filter ((post): post is Post => post != null)
|
||
.map (post => [post.id, post]),
|
||
)
|
||
|
||
return (
|
||
<div className="mt-3 max-h-72 overflow-y-auto rounded border">
|
||
<div className="divide-y">
|
||
{existingPostIds.map (postId => {
|
||
const post = postsById.get (postId)
|
||
if (post == null)
|
||
return <div key={postId} className="h-14"/>
|
||
|
||
return (
|
||
<PrefetchLink
|
||
key={postId}
|
||
to={`/posts/${ post.id }`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center gap-3 p-3">
|
||
<PostThumbnailPreview
|
||
url={post.thumbnail ?? ''}
|
||
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 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 [session, setSession] = useState<PostImportSession | null> (null)
|
||
const [loading, setLoading] = useState (false)
|
||
const [loadingRow, setLoadingRow] = useState<number | null> (null)
|
||
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
||
const [showExistingRows, setShowExistingRows] = useState (false)
|
||
const sessionRef = useRef<PostImportSession | null> (null)
|
||
const sessionIdRef = useRef<string | null> (null)
|
||
const persistedSearchRef = useRef<string | null> (null)
|
||
const persistSequenceRef = useRef (0)
|
||
|
||
const persistSession = async (
|
||
nextSession: PostImportSession,
|
||
): Promise<PostImportSession | null> => {
|
||
const sessionId =
|
||
sessionIdRef.current ?? generatePostImportSessionId ()
|
||
sessionIdRef.current = sessionId
|
||
const sequence = ++persistSequenceRef.current
|
||
const serialised = await serialisePostNewState (nextSession.rows)
|
||
if (serialised == null)
|
||
return null
|
||
if (persistSequenceRef.current !== sequence)
|
||
return sessionRef.current
|
||
|
||
savePostImportSession (sessionId, nextSession)
|
||
const loadedSession = loadPostImportSession (sessionId)
|
||
if (!(serialised.complete)
|
||
&& loadedSession?.rows.length !== nextSession.rows.length)
|
||
return null
|
||
|
||
const persistedSession = buildSession (nextSession.rows)
|
||
const path = appendPostNewSessionId (serialised.path, sessionId)
|
||
persistedSearchRef.current = (new URL (path, window.location.origin)).search
|
||
sessionRef.current = persistedSession
|
||
setSession (persistedSession)
|
||
navigate (path, { replace: true })
|
||
return persistedSession
|
||
}
|
||
|
||
const finishImport = () => {
|
||
const sessionId = sessionIdRef.current
|
||
if (sessionId != null)
|
||
clearPostImportSession (sessionId)
|
||
clearPostImportSourceDraft (message =>
|
||
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||
navigate ('/posts')
|
||
}
|
||
|
||
useEffect (() => {
|
||
let active = true
|
||
|
||
if (sessionRef.current != null && persistedSearchRef.current === location.search)
|
||
return () => {
|
||
}
|
||
|
||
const hydrate = async () => {
|
||
const parsed = await parsePostNewState (location.search)
|
||
if (!(active))
|
||
return
|
||
if (parsed == null)
|
||
{
|
||
navigate ('/posts/new', { replace: true })
|
||
return
|
||
}
|
||
|
||
const parsedSessionId = parsePostNewSessionId (location.search)
|
||
const sessionId = parsedSessionId ?? generatePostImportSessionId ()
|
||
sessionIdRef.current = sessionId
|
||
const loaded = loadPostImportSession (sessionId) ?? buildSession (parsed.rows)
|
||
persistedSearchRef.current = location.search
|
||
sessionRef.current = loaded
|
||
setSession (loaded)
|
||
|
||
if (parsed.shortcut)
|
||
{
|
||
try
|
||
{
|
||
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
|
||
params: { url: parsed.source } })
|
||
if (!(active))
|
||
return
|
||
const rows = initialisePreviewRows ([{
|
||
sourceRow: 1,
|
||
url: preview.url,
|
||
attributes: {
|
||
title: preview.title ?? '',
|
||
thumbnailBase: preview.thumbnailBase ?? '',
|
||
originalCreatedFrom: preview.originalCreatedFrom ?? '',
|
||
originalCreatedBefore: preview.originalCreatedBefore ?? '',
|
||
duration: preview.duration ?? '',
|
||
videoMs: preview.videoMs ?? '',
|
||
tags: preview.tags ?? '',
|
||
parentPostIds: '' },
|
||
fieldWarnings: preview.fieldWarnings ?? { },
|
||
baseWarnings: preview.baseWarnings ?? [],
|
||
validationErrors: { },
|
||
provenance: {
|
||
url: 'manual',
|
||
title: 'automatic',
|
||
thumbnailBase: 'automatic',
|
||
originalCreatedFrom: 'automatic',
|
||
originalCreatedBefore: 'automatic',
|
||
duration: 'automatic',
|
||
videoMs: 'automatic',
|
||
tags: 'automatic',
|
||
parentPostIds: 'automatic' },
|
||
tagSources: {
|
||
automatic: preview.tags ?? '',
|
||
manual: '' },
|
||
status:
|
||
Object.keys (preview.fieldWarnings ?? { }).length > 0
|
||
|| (preview.baseWarnings?.length ?? 0) > 0
|
||
? 'warning'
|
||
: 'ready',
|
||
skipReason: preview.existingPost?.id != null ? 'existing' : undefined,
|
||
existingPostId: preview.existingPost?.id,
|
||
resetSnapshot: {
|
||
url: preview.url,
|
||
attributes: {
|
||
title: preview.title ?? '',
|
||
thumbnailBase: preview.thumbnailBase ?? '',
|
||
originalCreatedFrom: preview.originalCreatedFrom ?? '',
|
||
originalCreatedBefore: preview.originalCreatedBefore ?? '',
|
||
duration: preview.duration ?? '',
|
||
videoMs: preview.videoMs ?? '',
|
||
tags: preview.tags ?? '',
|
||
parentPostIds: '' },
|
||
provenance: {
|
||
url: 'manual',
|
||
title: 'automatic',
|
||
thumbnailBase: 'automatic',
|
||
originalCreatedFrom: 'automatic',
|
||
originalCreatedBefore: 'automatic',
|
||
duration: 'automatic',
|
||
videoMs: 'automatic',
|
||
tags: 'automatic',
|
||
parentPostIds: 'automatic' },
|
||
tagSources: {
|
||
automatic: preview.tags ?? '',
|
||
manual: '' },
|
||
fieldWarnings: preview.fieldWarnings ?? { },
|
||
baseWarnings: preview.baseWarnings ?? [] } }])
|
||
const persisted = await persistSession (buildSession (rows))
|
||
if (persisted == null)
|
||
return
|
||
}
|
||
catch
|
||
{
|
||
if (active)
|
||
navigate ('/posts/new', { replace: true })
|
||
}
|
||
return
|
||
}
|
||
|
||
if (parsedSessionId == null)
|
||
{
|
||
await persistSession (loaded)
|
||
}
|
||
}
|
||
|
||
void hydrate ()
|
||
|
||
return () => {
|
||
active = false
|
||
}
|
||
}, [location.search, navigate])
|
||
|
||
useEffect (() => {
|
||
if (session != null)
|
||
sessionRef.current = session
|
||
}, [session])
|
||
|
||
useEffect (() => {
|
||
if (session == null || session.rows.length === 0)
|
||
return
|
||
if (session.rows.every (row => isCompletedReviewRow (row)))
|
||
finishImport ()
|
||
}, [session])
|
||
|
||
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 rows = session?.rows ?? []
|
||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||
const sortedRows =
|
||
session?.repairMode === 'failed'
|
||
? (
|
||
[...rows].sort ((a, b) => {
|
||
const aRepair = isRepairRow (a) ? 0 : 1
|
||
const bRepair = isRepairRow (b) ? 0 : 1
|
||
return aRepair - bRepair || a.sourceRow - b.sourceRow
|
||
}))
|
||
: rows
|
||
const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
|
||
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
|
||
const busy = loading || loadingRow != null
|
||
|
||
const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
|
||
if (busy)
|
||
return
|
||
|
||
const currentSession = sessionRef.current
|
||
if (currentSession == null)
|
||
return
|
||
|
||
const nextRows = currentSession.rows.map (row => {
|
||
if (row.sourceRow !== sourceRow)
|
||
return row
|
||
if (isExistingSkipRow (row)
|
||
|| row.importStatus === 'created'
|
||
|| isNonRecoverableFailedRow (row))
|
||
return row
|
||
return {
|
||
...row,
|
||
skipReason: checked ? 'manual' : undefined,
|
||
existingPostId: checked ? undefined : row.existingPostId }
|
||
})
|
||
await persistSession (buildSession (nextRows))
|
||
}
|
||
|
||
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 }
|
||
if (!(canEditReviewRow (row)))
|
||
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)
|
||
try
|
||
{
|
||
const dryRun = await apiPost<PreviewResponse> (
|
||
'/posts?dry=1',
|
||
buildDryRunFormData (nextRow))
|
||
const latestSession = sessionRef.current
|
||
if (latestSession == null)
|
||
return { saved: false, row: null }
|
||
|
||
const mergedRow = mergeDryRunRow (nextRow, dryRun)
|
||
const mergedRows = replaceImportRow (latestSession.rows, mergedRow)
|
||
const nextSession = await persistSession (buildSession (mergedRows))
|
||
if (nextSession == null)
|
||
return { saved: false, row: null }
|
||
const mergedTarget = nextSession.rows.find (
|
||
currentRow => currentRow.sourceRow === baseRow.sourceRow)
|
||
if (mergedTarget == null)
|
||
return { saved: false, row: null }
|
||
if (Object.keys (mergedTarget.validationErrors).length > 0)
|
||
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 = {
|
||
...nextRow,
|
||
validationErrors: requestError.response.data.errors ?? { },
|
||
baseWarnings: [],
|
||
fieldWarnings: { },
|
||
status: 'error' as const }
|
||
setMessageRow (errorRow)
|
||
return { saved: false, row: errorRow }
|
||
}
|
||
|
||
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 => (
|
||
<PostImportRowForm
|
||
row={row}
|
||
controls={controls}
|
||
onSave={saveRowDraft}/>) })
|
||
}
|
||
|
||
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 (busy)
|
||
return
|
||
|
||
const initialSession = sessionRef.current
|
||
if (initialSession == null)
|
||
return
|
||
|
||
setLoadingRow (sourceRow)
|
||
const originalRow = initialSession.rows.find (row => row.sourceRow === sourceRow)
|
||
if (originalRow == null)
|
||
{
|
||
setLoadingRow (null)
|
||
return
|
||
}
|
||
try
|
||
{
|
||
const pendingRows = retryImportRow (initialSession.rows, sourceRow)
|
||
const pendingSession = await persistSession (buildSession (pendingRows))
|
||
if (pendingSession == null)
|
||
return
|
||
|
||
const target = pendingSession.rows.find (row => row.sourceRow === sourceRow)
|
||
if (target == null)
|
||
return
|
||
|
||
const result = await apiPost<{ results: BulkApiRow[] }>(
|
||
'/posts/bulk',
|
||
buildBulkFormData ([target]))
|
||
if (result.results.length !== 1)
|
||
{
|
||
const latest = sessionRef.current ?? pendingSession
|
||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||
await persistSession (buildSession (restoredRows))
|
||
toast ({ title: '登録結果が不完全でした' })
|
||
return
|
||
}
|
||
|
||
const indexedResults = indexedBulkResults ([target], result.results)
|
||
const latestAfterImport = sessionRef.current ?? pendingSession
|
||
const mergedRows = mergeImportResults (latestAfterImport.rows, indexedResults)
|
||
const recoverableRows = indexedResults.filter (row =>
|
||
row.status === 'failed'
|
||
&& row.recoverable
|
||
&& Object.keys (row.errors ?? { }).length > 0)
|
||
const nextRows = mergedRows.map ((row): PostImportRow => {
|
||
const recoverable = recoverableRows.find (
|
||
failedRow => failedRow.sourceRow === row.sourceRow)
|
||
if (recoverable == null)
|
||
return row
|
||
return {
|
||
...row,
|
||
importStatus: 'pending',
|
||
recoverable: true,
|
||
validationErrors: recoverable.errors ?? { },
|
||
importErrors: undefined }
|
||
})
|
||
await persistSession (buildSession (nextRows))
|
||
}
|
||
catch
|
||
{
|
||
const latest = sessionRef.current ?? initialSession
|
||
const restoredRows = replaceImportRow (latest.rows, originalRow)
|
||
await persistSession (buildSession (restoredRows))
|
||
toast ({ title: '再試行に失敗しました' })
|
||
}
|
||
finally
|
||
{
|
||
setLoadingRow (null)
|
||
}
|
||
}
|
||
|
||
const submit = async () => {
|
||
const currentSession = sessionRef.current
|
||
if (currentSession == null || busy)
|
||
return
|
||
if (currentSession.rows.every (row => isCompletedReviewRow (row)))
|
||
{
|
||
finishImport ()
|
||
return
|
||
}
|
||
|
||
setLoading (true)
|
||
try
|
||
{
|
||
const processingRows = processableImportRows (currentSession.rows)
|
||
if (processingRows.length === 0)
|
||
{
|
||
finishImport ()
|
||
return
|
||
}
|
||
|
||
const result = await apiPost<{ results: BulkApiRow[] }>(
|
||
'/posts/bulk',
|
||
buildBulkFormData (processingRows))
|
||
if (result.results.length !== processingRows.length)
|
||
{
|
||
toast ({ title: '登録結果が不完全でした' })
|
||
return
|
||
}
|
||
|
||
const indexedResults = indexedBulkResults (processingRows, result.results)
|
||
const latestAfterImport = sessionRef.current ?? buildSession (currentSession.rows)
|
||
const mergedResults = mergeImportResults (latestAfterImport.rows, indexedResults)
|
||
const recoverableRows = indexedResults.filter (row =>
|
||
row.status === 'failed'
|
||
&& row.recoverable
|
||
&& Object.keys (row.errors ?? { }).length > 0)
|
||
const nextRows = mergedResults.map ((row): PostImportRow => {
|
||
const recoverable = recoverableRows.find (
|
||
failedRow => failedRow.sourceRow === row.sourceRow)
|
||
if (recoverable == null)
|
||
return row
|
||
return {
|
||
...row,
|
||
importStatus: 'pending',
|
||
recoverable: true,
|
||
validationErrors: recoverable.errors ?? { },
|
||
importErrors: undefined }
|
||
})
|
||
await persistSession (buildSession (nextRows))
|
||
}
|
||
catch
|
||
{
|
||
toast ({ title: '登録に失敗しました' })
|
||
}
|
||
finally
|
||
{
|
||
setLoading (false)
|
||
}
|
||
}
|
||
|
||
if (!(editable))
|
||
return <Forbidden/>
|
||
|
||
if (session == null)
|
||
return null
|
||
|
||
return (
|
||
<>
|
||
<Helmet>
|
||
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
|
||
</Helmet>
|
||
|
||
<MainArea className="min-h-0">
|
||
<div className="mx-auto max-w-6xl space-y-4 p-4">
|
||
<PageTitle>追加内容確認</PageTitle>
|
||
|
||
{counts.existingSkipped > 0 && (
|
||
<div className="rounded-lg border p-4">
|
||
<button
|
||
type="button"
|
||
className="text-left text-sm font-medium"
|
||
onClick={() => setShowExistingRows (current => !(current))}>
|
||
既存投稿による自動スキップ {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 open={showExistingRows} 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={busy}
|
||
retryDisabled={busy}
|
||
skipDisabled={
|
||
busy
|
||
|| isExistingSkipRow (row)
|
||
|| row.importStatus === 'created'
|
||
|| isNonRecoverableFailedRow (row)}
|
||
onEdit={() => editRow (row)}
|
||
onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
|
||
onRetry={canRetryResultRow (row) ? () => retry (row.sourceRow) : undefined}
|
||
rowMessages={resultRowMessages (row)}/>
|
||
</div>))}
|
||
</div>
|
||
</div>
|
||
</MainArea>
|
||
|
||
<PostImportFooter
|
||
loading={busy}
|
||
creatableCount={counts.creatable}
|
||
manualSkippedCount={counts.manualSkipped}
|
||
existingSkippedCount={counts.existingSkipped}
|
||
pendingOrErrorCount={counts.pendingOrError}
|
||
onBack={() => navigate (-1)}
|
||
onSubmit={() => submit ()}/>
|
||
</>)
|
||
}
|
||
|
||
const PostImportFooter = (
|
||
{ loading,
|
||
creatableCount,
|
||
manualSkippedCount,
|
||
existingSkippedCount,
|
||
pendingOrErrorCount,
|
||
onBack,
|
||
onSubmit }: { loading: 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>error/未処理 {pendingOrErrorCount}件</span>
|
||
</div>
|
||
<div className="flex flex-col gap-2 sm:flex-row">
|
||
<Button type="button" variant="outline" onClick={onBack} disabled={loading}>
|
||
URL リスト入力へ戻る
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={loading || creatableCount === 0}>
|
||
{creatableCount > 1 ? '一括追加' : '追加'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>)
|
||
|
||
export default PostImportReviewPage
|