352 行
11 KiB
TypeScript
352 行
11 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
import { Helmet } from 'react-helmet-async'
|
||
import { useNavigate,
|
||
useParams,
|
||
useSearchParams } from 'react-router-dom'
|
||
|
||
import PageTitle from '@/components/common/PageTitle'
|
||
import MainArea from '@/components/layout/MainArea'
|
||
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog'
|
||
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||
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 { loadPostImportSession,
|
||
creatableImportRows,
|
||
mergeImportResults,
|
||
mergeValidatedImportRows,
|
||
processableImportRows,
|
||
reviewSummaryCounts,
|
||
savePostImportSession,
|
||
type PostImportResultRow,
|
||
type PostImportRow,
|
||
type PostImportSession } from '@/lib/postImportSession'
|
||
import Forbidden from '@/pages/Forbidden'
|
||
|
||
import type { FC } from 'react'
|
||
|
||
import type { User } from '@/types'
|
||
|
||
type Props = { user: User | null }
|
||
|
||
type Draft = {
|
||
url: string
|
||
title: string
|
||
thumbnailBase: string
|
||
originalCreatedFrom: string
|
||
originalCreatedBefore: string
|
||
duration: string
|
||
tags: string
|
||
parentPostIds: string }
|
||
|
||
|
||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||
const editable = canEditContent (user)
|
||
const navigate = useNavigate ()
|
||
const { sessionId } = useParams ()
|
||
const [searchParams, setSearchParams] = useSearchParams ()
|
||
|
||
const [session, setSession] = useState<PostImportSession | null> (null)
|
||
const [loading, setLoading] = useState (false)
|
||
const [missing, setMissing] = useState (false)
|
||
const [savingRow, setSavingRow] = 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 rows = session?.rows ?? []
|
||
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
||
const editingRow =
|
||
Number.isFinite (editingSourceRow)
|
||
? rows.find (_1 => _1.sourceRow === editingSourceRow) ?? null
|
||
: null
|
||
const counts = useMemo (() => reviewSummaryCounts (rows), [rows])
|
||
const processable = useMemo (
|
||
() => processableImportRows (rows),
|
||
[rows])
|
||
const creatable = useMemo (
|
||
() => creatableImportRows (rows),
|
||
[rows])
|
||
const reviewRows =
|
||
session?.repairMode === 'failed'
|
||
? [...rows].sort ((a, b) => {
|
||
const aFailed = a.importStatus === 'failed' ? 0 : 1
|
||
const bFailed = b.importStatus === 'failed' ? 0 : 1
|
||
return aFailed - bFailed || a.sourceRow - b.sourceRow
|
||
})
|
||
: rows
|
||
|
||
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 updateSessionRows = (nextRows: PostImportRow[]) =>
|
||
setSession (current =>
|
||
current ? { ...current, rows: nextRows } : current)
|
||
|
||
const saveDraft = async (draft: Draft): Promise<boolean> => {
|
||
if (session == null)
|
||
return false
|
||
if (editingRow == null)
|
||
return false
|
||
|
||
const urlChanged = draft.url !== editingRow.url
|
||
const nextProvenance = { ...editingRow.provenance }
|
||
const nextAttributes = { ...editingRow.attributes }
|
||
const nextTagSources = {
|
||
automatic: editingRow.tagSources?.automatic ?? '',
|
||
manual: editingRow.tagSources?.manual ?? '' }
|
||
const draftFields = [
|
||
['title', draft.title],
|
||
['thumbnailBase', draft.thumbnailBase],
|
||
['originalCreatedFrom', draft.originalCreatedFrom],
|
||
['originalCreatedBefore', draft.originalCreatedBefore],
|
||
['duration', draft.duration],
|
||
['parentPostIds', draft.parentPostIds]] as const
|
||
draftFields.forEach (([field, value]) => {
|
||
nextAttributes[field] = value
|
||
nextProvenance[field] =
|
||
value !== String (editingRow.attributes[field] ?? '')
|
||
? 'manual'
|
||
: (editingRow.provenance[field] ?? 'automatic')
|
||
})
|
||
nextAttributes.tags = draft.tags
|
||
if (draft.tags !== String (editingRow.attributes.tags ?? ''))
|
||
{
|
||
nextProvenance.tags = 'manual'
|
||
nextTagSources.manual = draft.tags
|
||
}
|
||
else
|
||
{
|
||
nextProvenance.tags = editingRow.provenance.tags ?? 'automatic'
|
||
nextTagSources.manual = editingRow.tagSources?.manual ?? ''
|
||
}
|
||
|
||
setSavingRow (editingRow.sourceRow)
|
||
const nextRows = session.rows.map (row =>
|
||
row.sourceRow === editingRow.sourceRow
|
||
? {
|
||
...row,
|
||
url: draft.url,
|
||
attributes: nextAttributes,
|
||
provenance: {
|
||
...nextProvenance,
|
||
url: urlChanged ? 'manual' : (editingRow.provenance.url ?? 'manual') },
|
||
tagSources: nextTagSources,
|
||
importStatus: row.importStatus === 'created' ? 'created' : 'pending',
|
||
importErrors: undefined }
|
||
: row)
|
||
try
|
||
{
|
||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||
rows: nextRows
|
||
.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: urlChanged ? editingRow.sourceRow : -1 })
|
||
updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows))
|
||
setSearchParams ({ })
|
||
return true
|
||
}
|
||
catch
|
||
{
|
||
toast ({ title: '行の再検証に失敗しました' })
|
||
return false
|
||
}
|
||
finally
|
||
{
|
||
setSavingRow (null)
|
||
}
|
||
}
|
||
|
||
const submit = async () => {
|
||
if (sessionId == null || session == null || processable.length === 0)
|
||
return
|
||
|
||
setLoading (true)
|
||
try
|
||
{
|
||
const result = await apiPost<{
|
||
created: number
|
||
skipped: number
|
||
failed: number
|
||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||
rows: processable.map (row => ({
|
||
sourceRow: row.sourceRow,
|
||
url: row.url,
|
||
attributes: row.attributes,
|
||
provenance: row.provenance,
|
||
tagSources: row.tagSources,
|
||
metadataUrl: row.metadataUrl })) })
|
||
const nextRows = mergeImportResults (session.rows, result.rows)
|
||
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
|
||
setSession (nextSession)
|
||
const saved = savePostImportSession (sessionId, nextSession, message =>
|
||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||
if (!(saved))
|
||
return
|
||
navigate (`/posts/import/${ sessionId }/result`)
|
||
}
|
||
catch
|
||
{
|
||
toast ({ title: '登録に失敗しました' })
|
||
}
|
||
finally
|
||
{
|
||
setLoading (false)
|
||
}
|
||
}
|
||
|
||
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>
|
||
<div className="text-red-700 dark:text-red-300">取込状態が見つかりません.</div>
|
||
<Button type="button" onClick={() => navigate ('/posts/import')}>
|
||
URL リスト入力へ戻る
|
||
</Button>
|
||
</div>
|
||
</MainArea>)
|
||
}
|
||
|
||
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>
|
||
|
||
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||
dark:bg-neutral-900">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<SummaryChip label="全件" value={counts.total}/>
|
||
<SummaryChip label="登録対象" value={counts.submittable}/>
|
||
<SummaryChip label="要修正" value={counts.invalid}/>
|
||
<SummaryChip label="スキップ予定" value={counts.skipPlanned}/>
|
||
<SummaryChip label="登録済み" value={counts.created}/>
|
||
<SummaryChip label="失敗" value={counts.failed}/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{reviewRows.map (row => (
|
||
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
||
<PostImportRowSummary
|
||
row={row}
|
||
onEdit={() => {
|
||
setSearchParams ({ edit: String (row.sourceRow) })
|
||
}}/>
|
||
</div>))}
|
||
</div>
|
||
</div>
|
||
</MainArea>
|
||
|
||
<PostImportFooter
|
||
loading={loading}
|
||
invalidCount={counts.invalid}
|
||
processableCount={processable.length}
|
||
creatableCount={creatable.length}
|
||
skipPlannedCount={counts.skipPlanned}
|
||
onBack={() => navigate ('/posts/import')}
|
||
onSubmit={submit}/>
|
||
|
||
<PostImportRowDialog
|
||
open={editingRow != null}
|
||
row={editingRow}
|
||
saving={savingRow != null}
|
||
onOpenChange={open => {
|
||
if (!(open))
|
||
setSearchParams ({ })
|
||
}}
|
||
onSave={saveDraft}/>
|
||
</>)
|
||
}
|
||
|
||
const PostImportFooter = (
|
||
{ loading,
|
||
invalidCount,
|
||
processableCount,
|
||
creatableCount,
|
||
skipPlannedCount,
|
||
onBack,
|
||
onSubmit }: {
|
||
loading: boolean
|
||
invalidCount: number
|
||
processableCount: number
|
||
creatableCount: number
|
||
skipPlannedCount: 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">
|
||
<PostImportStatusBadge value="pending"/>
|
||
<span>処理対象 {processableCount} 件</span>
|
||
<PostImportStatusBadge value="ready"/>
|
||
<span>登録対象 {creatableCount} 件</span>
|
||
<PostImportStatusBadge value="skipped"/>
|
||
<span>スキップ予定 {skipPlannedCount} 件</span>
|
||
<PostImportStatusBadge value="error"/>
|
||
<span>要修正 {invalidCount} 件</span>
|
||
</div>
|
||
<div className="flex flex-col gap-2 sm:flex-row">
|
||
<Button type="button" variant="outline" onClick={onBack}>
|
||
URL リスト入力へ戻る
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={loading || processableCount === 0}>
|
||
取込を実行
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>)
|
||
|
||
const SummaryChip = ({ label, value }: { label: string
|
||
value: number }) => (
|
||
<div className="rounded-full border border-border bg-background px-3 py-1 text-sm">
|
||
{label} {value}
|
||
</div>)
|
||
|
||
export default PostImportReviewPage
|