291 行
9.8 KiB
TypeScript
291 行
9.8 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
import { Helmet } from 'react-helmet-async'
|
||
import { useNavigate, useParams } from 'react-router-dom'
|
||
|
||
import FieldError from '@/components/common/FieldError'
|
||
import PageTitle from '@/components/common/PageTitle'
|
||
import PrefetchLink from '@/components/PrefetchLink'
|
||
import MainArea from '@/components/layout/MainArea'
|
||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||
import { Button } from '@/components/ui/button'
|
||
import { toast } from '@/components/ui/use-toast'
|
||
import { SITE_TITLE } from '@/config'
|
||
import { apiPost } from '@/lib/api'
|
||
import { canEditContent } from '@/lib/users'
|
||
import { clearPostImportSourceDraft,
|
||
initialisePreviewRows,
|
||
loadPostImportSession,
|
||
mergeImportResults,
|
||
mergeValidatedImportRows,
|
||
resultSummaryCounts,
|
||
retryImportRow,
|
||
savePostImportSession } from '@/lib/postImportSession'
|
||
import Forbidden from '@/pages/Forbidden'
|
||
|
||
import type { FC } from 'react'
|
||
|
||
import type { PostImportResultRow, PostImportRow } from '@/lib/postImportSession'
|
||
import type { PostImportSession } from '@/lib/postImportSession'
|
||
import type { User } from '@/types'
|
||
|
||
type Props = { user: User | null }
|
||
|
||
const rowMessages = (row: PostImportRow): string[] =>
|
||
Object.values (row.importErrors ?? { }).flat ()
|
||
|
||
|
||
const PostImportResultPage: FC<Props> = ({ user }) => {
|
||
const editable = canEditContent (user)
|
||
const navigate = useNavigate ()
|
||
const { sessionId } = useParams ()
|
||
|
||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||
const [missing, setMissing] = useState (false)
|
||
const [loadingRow, setLoadingRow] = useState<number | null> (null)
|
||
|
||
useEffect (() => {
|
||
if (sessionId == null)
|
||
return
|
||
|
||
const loaded = loadPostImportSession (sessionId, message =>
|
||
toast ({ title: '取込状態を復元できませんでした', description: message }))
|
||
setSession (loaded)
|
||
setMissing (loaded == null)
|
||
}, [sessionId])
|
||
|
||
useEffect (() => {
|
||
if (sessionId == null || session == null)
|
||
return
|
||
|
||
savePostImportSession (sessionId, session, message =>
|
||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||
}, [session, sessionId])
|
||
|
||
const counts = useMemo (
|
||
() => resultSummaryCounts (session?.rows ?? []),
|
||
[session])
|
||
|
||
const retry = async (sourceRow: number) => {
|
||
if (session == null || sessionId == null)
|
||
return
|
||
|
||
setLoadingRow (sourceRow)
|
||
try
|
||
{
|
||
const pendingRows = retryImportRow (session.rows, sourceRow)
|
||
setSession ({ ...session, rows: pendingRows })
|
||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||
rows: pendingRows
|
||
.filter (row => row.importStatus !== 'created')
|
||
.map (row => ({
|
||
sourceRow: row.sourceRow,
|
||
url: row.url,
|
||
attributes: row.attributes,
|
||
provenance: row.provenance,
|
||
tagSources: row.tagSources,
|
||
metadataUrl: row.metadataUrl })),
|
||
changed_row: -1 })
|
||
const validatedRows = mergeValidatedImportRows (
|
||
pendingRows,
|
||
initialisePreviewRows (validated.rows))
|
||
const nextSession = {
|
||
...session,
|
||
rows: validatedRows,
|
||
repairMode: 'failed' as const }
|
||
setSession (nextSession)
|
||
const target = validatedRows.find (_1 => _1.sourceRow === sourceRow)
|
||
if (target == null)
|
||
return
|
||
if (Object.keys (target.validationErrors ?? { }).length > 0)
|
||
{
|
||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||
if (saved)
|
||
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||
return
|
||
}
|
||
|
||
const result = await apiPost<{
|
||
created: number
|
||
skipped: number
|
||
failed: number
|
||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||
rows: [{
|
||
sourceRow: target.sourceRow,
|
||
url: target.url,
|
||
attributes: target.attributes,
|
||
provenance: target.provenance,
|
||
tagSources: target.tagSources,
|
||
metadataUrl: target.metadataUrl }] })
|
||
const mergedRows = mergeImportResults (nextSession.rows, result.rows)
|
||
const recoverableRows = result.rows.filter (row =>
|
||
row.status === 'failed'
|
||
&& row.recoverable
|
||
&& Object.keys (row.errors ?? { }).length > 0)
|
||
const nextRows = mergedRows.map ((row): PostImportRow => {
|
||
const recoverable = recoverableRows.find (_1 => _1.sourceRow === row.sourceRow)
|
||
if (recoverable == null)
|
||
return row
|
||
return {
|
||
...row,
|
||
importStatus: 'pending',
|
||
validationErrors: recoverable.errors ?? { },
|
||
importErrors: undefined }
|
||
})
|
||
const recoverableTarget = nextRows.find (_1 => _1.sourceRow === sourceRow)
|
||
const resultSession = {
|
||
...nextSession,
|
||
rows: nextRows,
|
||
repairMode: recoverableTarget == null ? 'all' as const : 'failed' as const }
|
||
setSession (resultSession)
|
||
if (recoverableTarget != null
|
||
&& Object.keys (recoverableTarget.validationErrors).length > 0)
|
||
{
|
||
const saved = savePostImportSession (sessionId, resultSession, message =>
|
||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||
if (saved)
|
||
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||
return
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
setSession (session)
|
||
toast ({ title: '再試行に失敗しました' })
|
||
}
|
||
finally
|
||
{
|
||
setLoadingRow (null)
|
||
}
|
||
}
|
||
|
||
const openRepair = (sourceRow: number) => {
|
||
if (session == null || sessionId == null)
|
||
return
|
||
|
||
const nextSession = { ...session, repairMode: 'failed' as const }
|
||
setSession (nextSession)
|
||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||
if (!(saved))
|
||
return
|
||
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
|
||
}
|
||
|
||
if (!(editable))
|
||
return <Forbidden/>
|
||
|
||
if (missing || sessionId == null || session == null)
|
||
{
|
||
return (
|
||
<MainArea>
|
||
<div className="mx-auto max-w-4xl space-y-4 p-4">
|
||
<PageTitle>投稿インポート結果</PageTitle>
|
||
<FieldError messages={['取込状態が見つかりません.']}/>
|
||
<Button type="button" onClick={() => navigate ('/posts/import')}>
|
||
URL リスト入力へ戻る
|
||
</Button>
|
||
</div>
|
||
</MainArea>)
|
||
}
|
||
|
||
return (
|
||
<MainArea>
|
||
<Helmet>
|
||
<title>{`投稿インポート結果 | ${ SITE_TITLE }`}</title>
|
||
</Helmet>
|
||
|
||
<div className="mx-auto max-w-5xl space-y-4 p-4">
|
||
<PageTitle>登録結果</PageTitle>
|
||
|
||
<div className="text-sm text-neutral-700 dark:text-neutral-200">
|
||
登録成功 {counts.created}件 スキップ {counts.skipped}件 失敗 {counts.failed}件
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{session.rows.map (row => {
|
||
const displayStatus = displayPostImportStatus (row)
|
||
const canEdit = row.importStatus === 'failed'
|
||
const canRetry =
|
||
row.importStatus === 'failed'
|
||
&& Object.keys (row.validationErrors).length === 0
|
||
return (
|
||
<div
|
||
key={row.sourceRow}
|
||
className="rounded-lg border p-4 transition-shadow hover:shadow-sm">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start
|
||
md:justify-between">
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-sm font-medium">行 {row.sourceRow}</span>
|
||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||
</div>
|
||
<div className="text-sm text-neutral-700 dark:text-neutral-200">
|
||
{String (row.attributes.title ?? '') || row.url}
|
||
</div>
|
||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||
{row.url}
|
||
</div>
|
||
<FieldError messages={rowMessages (row)}/>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 sm:flex-row">
|
||
{(row.createdPostId != null || row.existingPostId != null) && (
|
||
<Button type="button" variant="outline" asChild>
|
||
<PrefetchLink
|
||
to={`/posts/${ row.createdPostId ?? row.existingPostId }`}>
|
||
投稿を開く
|
||
</PrefetchLink>
|
||
</Button>)}
|
||
{canEdit && (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => openRepair (row.sourceRow)}>
|
||
編輯
|
||
</Button>)}
|
||
{canRetry && (
|
||
<Button
|
||
type="button"
|
||
onClick={() => retry (row.sourceRow)}
|
||
disabled={loadingRow === row.sourceRow}>
|
||
再試行
|
||
</Button>)}
|
||
</div>
|
||
</div>
|
||
</div>)})}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 sm:flex-row">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => {
|
||
const nextSession = { ...session, repairMode: 'all' as const }
|
||
setSession (nextSession)
|
||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||
if (!(saved))
|
||
return
|
||
navigate (`/posts/import/${ sessionId }/review`)
|
||
}}>
|
||
確認画面へ戻る
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => {
|
||
clearPostImportSourceDraft (message =>
|
||
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
||
navigate ('/posts/import')
|
||
}}>
|
||
新しい URL リストを入力
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</MainArea>)
|
||
}
|
||
|
||
export default PostImportResultPage
|