ファイル
btrc-hub/frontend/src/pages/posts/PostImportReviewPage.tsx
T
2026-07-18 00:31:02 +09:00

1024 行
33 KiB
TypeScript
Raw Blame 履歴

このファイルには曖昧(ambiguous)なUnicode文字が含まれてゐます
このファイルには,他の文字と見間違える可能性があるUnicode文字が含まれてゐます. それが意図的なものと考えられる場合は,この警告を無視して構ゐません. それらの文字を表示するにはエスケープボタンを使用します.
import { AnimatePresence, motion } from 'framer-motion'
import { 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 {
appendPostNewSessionId,
parsePostNewState,
parsePostNewSessionId,
serialisePostNewState,
} from '@/lib/postNewQueryState'
import {
applyThumbnailWarning,
buildNextEditedRow,
canEditReviewRow,
canRetryResultRow,
clearPostImportSession,
clearPostImportSourceDraft,
generatePostImportSessionId,
hasThumbnailBaseValue,
initialisePreviewRows,
isCompletedReviewRow,
isExistingSkipRow,
isNonRecoverableFailedRow,
mergeImportResults,
processableImportRows,
replaceImportRow,
resultRepairMode,
resultRowMessages,
reviewSummaryCounts,
retryImportRow,
loadPostImportSession,
serialisedPostImportRowsEqual,
savePostImportSession,
} from '@/lib/postImportSession'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
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 { User } from '@/types'
type Props = { user: User | null }
type PreviewResponse = {
url: string
title?: string
thumbnailBase?: string
originalCreatedFrom?: string
originalCreatedBefore?: string
parentPostIds?: string
duration?: string
videoMs?: number
tags?: string
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
validationErrors?: Record<string, string[]>
existingPost?: {
id: number
title: string
url: string
thumbnailUrl?: string } | null }
type BulkApiRow = {
status: 'created' | 'skipped' | 'failed'
post?: { id: number }
existingPost?: {
id: number
title: string
url: string
thumbnailUrl?: string } | null
fieldWarnings?: Record<string, string[]>
baseWarnings?: string[]
errors?: Record<string, string[]>
baseErrors?: string[]
recoverable?: boolean }
type ExistingSkippedRowsProps = {
id: string
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 ?? ''))
if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
formData.append ('thumbnail', row.thumbnailFile)
return formData
}
const mergeDryRunRow = (
currentRow: PostImportRow,
result: PreviewResponse,
): PostImportRow =>
applyThumbnailWarning ({
...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 (result.parentPostIds ?? 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,
existingPost: result.existingPost ?? undefined,
importStatus:
currentRow.importStatus === 'created'
? 'created'
: 'pending',
recoverable: undefined })
const buildPreviewResetSnapshot = (
preview: PreviewResponse,
): PostImportRow['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: String (preview.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 ?? [],
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' && result.existingPost != null)
{
return {
sourceRow: row.sourceRow,
status: 'skipped',
existingPostId: result.existingPost.id,
existingPost: result.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: PreviewResponse,
): PostImportRow => {
const fieldWarnings = preview.fieldWarnings ?? { }
const baseWarnings = preview.baseWarnings ?? []
const validationErrors = 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 },
tagSources: {
automatic: currentRow.tagSources?.automatic ?? '',
manual: currentRow.tagSources?.manual ?? '' },
url: preview.url,
fieldWarnings,
baseWarnings,
validationErrors,
existingPostId: preview.existingPost?.id,
existingPost: preview.existingPost ?? undefined,
skipReason:
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.tagSources.automatic = preview.tags ?? ''
if (currentRow.provenance.parentPostIds !== 'manual')
nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
if (currentRow.provenance.videoMs !== 'manual')
nextRow.attributes.videoMs = preview.videoMs ?? ''
if (currentRow.provenance.duration !== 'manual')
nextRow.attributes.duration = preview.duration ?? ''
return applyThumbnailWarning (nextRow)
}
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 ?? '' }))))
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 }) => {
const posts = rows
.map (row => row.existingPost)
.filter ((post): post is NonNullable<PostImportRow['existingPost']> => post != null)
return (
<div id={id} className="mt-3 max-h-72 overflow-y-auto rounded border">
<div className="divide-y">
{posts.map (post => {
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.thumbnailUrl ?? ''}
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 previewSequenceRef = 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
const saved = savePostImportSession (sessionId, nextSession)
const loadedSession = loadPostImportSession (sessionId)
const canRecoverFromUrlOnly = serialised.complete
if (!(canRecoverFromUrlOnly)
&& (!(saved))
&& !(serialisedPostImportRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
return null
if (saved
&& !(serialisedPostImportRowsEqual (loadedSession?.rows ?? [], nextSession.rows)))
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
const previewSequence = ++previewSequenceRef.current
const controllers = new Set<AbortController> ()
if (sessionRef.current != null && persistedSearchRef.current === location.search)
return () => {
previewSequenceRef.current = previewSequence
}
const refreshRows = async (baseSession: PostImportSession) => {
let nextIndex = 0
const previews = new Map<number, PreviewResponse> ()
const worker = async () => {
while (active)
{
const index = nextIndex
++nextIndex
if (index >= baseSession.rows.length)
return
const baseRow = baseSession.rows[index]
if (baseRow.skipReason === 'manual'
|| baseRow.importStatus === 'created'
|| baseRow.importStatus === 'skipped'
|| isNonRecoverableFailedRow (baseRow))
continue
const controller = new AbortController ()
controllers.add (controller)
try
{
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
params: { url: baseRow.url },
signal: controller.signal })
if (!(active) || previewSequenceRef.current !== previewSequence)
return
previews.set (baseRow.sourceRow, preview)
}
catch (requestError)
{
if (controller.signal.aborted)
return
if (!(active) || previewSequenceRef.current !== previewSequence)
return
if (!(isApiError (requestError)))
continue
}
finally
{
controllers.delete (controller)
}
}
}
await Promise.all (
Array.from ({ length: Math.min (4, baseSession.rows.length) }, () => worker ()))
if (!(active) || previewSequenceRef.current !== previewSequence)
return
const latestSession = sessionRef.current ?? baseSession
const nextRows = latestSession.rows.map (row => {
const preview = previews.get (row.sourceRow)
if (preview == null
|| row.skipReason === 'manual'
|| row.importStatus === 'created'
|| row.importStatus === 'skipped'
|| isNonRecoverableFailedRow (row))
return row
return mergePreviewRow (row, preview)
})
await persistSession (buildSession (nextRows))
}
const hydrate = async () => {
const parsedSessionId = parsePostNewSessionId (location.search)
const sessionId = parsedSessionId ?? generatePostImportSessionId ()
const loadedSession = loadPostImportSession (sessionId)
const parsed = await parsePostNewState (location.search)
if (!(active))
return
if (parsed == null && loadedSession == null)
{
navigate ('/posts/new', { replace: true })
return
}
sessionIdRef.current = sessionId
const loaded = loadedSession ?? buildSession (parsed?.rows ?? [])
persistedSearchRef.current = location.search
sessionRef.current = loaded
setSession (loaded)
if (loadedSession == null && parsed?.shortcut)
{
try
{
const preview = await apiGet<PreviewResponse> ('/posts/preview', {
params: { url: parsed.source } })
if (!(active))
return
const rows = [applyThumbnailWarning (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: String (preview.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,
existingPost: preview.existingPost ?? undefined,
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: String (preview.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 ?? [],
metadataUrl: preview.url } }])[0])]
const persisted = await persistSession (buildSession (rows))
if (persisted == null)
return
}
catch
{
if (active)
navigate ('/posts/new', { replace: true })
}
return
}
void refreshRows (loaded)
}
void hydrate ()
return () => {
active = false
controllers.forEach (controller => controller.abort ())
}
}, [location.search, navigate])
useEffect (() => {
if (session != null)
sessionRef.current = session
}, [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')
return row
return {
...row,
skipReason: checked ? 'manual' : undefined,
existingPostId: checked ? undefined : row.existingPostId,
existingPost: checked ? undefined : row.existingPost }
})
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,
thumbnailFile: undefined }
: row
const urlChanged = draft.url !== baseRow.url
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
let candidateRow = nextRow
try
{
candidateRow =
urlChanged
? mergePreviewRow (
nextRow,
await apiGet<PreviewResponse> ('/posts/preview', {
params: { url: nextRow.url } }))
: nextRow
const dryRun = await apiPost<PreviewResponse> (
'/posts?dry=1',
buildDryRunFormData (candidateRow))
const latestSession = sessionRef.current
if (latestSession == null)
return { saved: false, row: null }
const mergedRow = mergeDryRunRow (candidateRow, 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 = {
...candidateRow,
validationErrors: requestError.response.data.errors ?? { },
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: PostImportRowDraft
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) => {
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 nextRows = mergeImportResults (latestAfterImport.rows, indexedResults)
.map (row => applyThumbnailWarning (row))
const persisted = await persistSession (buildSession (nextRows))
if (persisted != null
&& persisted.rows.every (row => isCompletedReviewRow (row)))
finishImport ()
}
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)
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 nextRows = mergeImportResults (latestAfterImport.rows, indexedResults)
.map (row => applyThumbnailWarning (row))
const persisted = await persistSession (buildSession (nextRows))
if (persisted != null
&& persisted.rows.every (row => isCompletedReviewRow (row)))
finishImport ()
}
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"
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={busy}
retryDisabled={busy}
skipDisabled={
busy
|| 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
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> {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 > 1 ? '一括追加' : '追加'}
</Button>
</div>
</div>
</div>)
export default PostImportReviewPage