このコミットが含まれているのは:
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useNavigate,
|
||||
useParams,
|
||||
useSearchParams } from 'react-router-dom'
|
||||
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
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,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRows,
|
||||
reviewSummaryCounts,
|
||||
rowMessages,
|
||||
savePostImportSession,
|
||||
submittableImportRows,
|
||||
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))
|
||||
return
|
||||
|
||||
const loaded = loadPostImportSession (sessionId)
|
||||
setSession (loaded)
|
||||
setMissing (loaded == null)
|
||||
}, [sessionId])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(sessionId) || !(session))
|
||||
return
|
||||
|
||||
savePostImportSession (sessionId, session)
|
||||
}, [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 submittable = useMemo (
|
||||
() => submittableImportRows (rows).filter (row =>
|
||||
Object.keys (row.validationErrors ?? { }).length === 0),
|
||||
[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', behaviour: 'smooth' })
|
||||
}, [editingRow, session?.repairMode])
|
||||
|
||||
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||
setSession (current =>
|
||||
current ? { ...current, rows: nextRows } : current)
|
||||
|
||||
const saveDraft = async (draft: Draft): Promise<boolean> => {
|
||||
if (!(session))
|
||||
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'),
|
||||
existing: session.existing,
|
||||
changed_row: urlChanged ? editingRow.sourceRow : -1 })
|
||||
updateSessionRows (mergeValidatedImportRows (nextRows, validated.rows))
|
||||
setSearchParams ({ })
|
||||
return true
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '行の再検証に失敗しました' })
|
||||
return false
|
||||
}
|
||||
finally
|
||||
{
|
||||
setSavingRow (null)
|
||||
}
|
||||
}
|
||||
|
||||
const updateExisting = async (value: 'skip' | 'error') => {
|
||||
if (!(session))
|
||||
return
|
||||
|
||||
const pendingRows = session.rows.map (row =>
|
||||
row.importStatus === 'created'
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
importStatus: 'pending' as const,
|
||||
importErrors: undefined })
|
||||
setSession ({ ...session, existing: value, rows: pendingRows })
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
|
||||
rows: pendingRows.filter (row => row.importStatus !== 'created'),
|
||||
existing: value,
|
||||
changed_row: -1 })
|
||||
updateSessionRows (mergeValidatedImportRows (pendingRows, validated.rows))
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!(sessionId) || !(session) || submittable.length === 0)
|
||||
return
|
||||
|
||||
setLoading (true)
|
||||
try
|
||||
{
|
||||
const result = await apiPost<{
|
||||
created: number
|
||||
skipped: number
|
||||
failed: number
|
||||
rows: PostImportResultRow[] }> ('/posts/import', {
|
||||
rows: submittable.map (row => ({
|
||||
sourceRow: row.sourceRow,
|
||||
url: row.url,
|
||||
attributes: row.attributes,
|
||||
provenance: row.provenance,
|
||||
tagSources: row.tagSources,
|
||||
metadataUrl: row.metadataUrl })),
|
||||
existing: session.existing })
|
||||
const nextRows = mergeImportResults (session.rows, result.rows)
|
||||
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
|
||||
setSession (nextSession)
|
||||
savePostImportSession (sessionId, nextSession)
|
||||
navigate (`/posts/import/${ sessionId }/result`)
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ title: '登録に失敗しました' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
if (missing || !(sessionId) || !(session))
|
||||
{
|
||||
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-6xl space-y-4 p-4 pb-28">
|
||||
<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="rounded-lg border bg-white p-4 dark:border-neutral-700
|
||||
dark:bg-neutral-900">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
URL {rows.length} 件
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span>既存投稿</span>
|
||||
<select
|
||||
value={session.existing}
|
||||
onChange={ev => updateExisting (ev.target.value as 'skip' | 'error')}
|
||||
className="rounded border border-border bg-background px-3 py-2">
|
||||
<option value="skip">スキップ</option>
|
||||
<option value="error">エラー</option>
|
||||
</select>
|
||||
</label>
|
||||
</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) })
|
||||
}}/>
|
||||
{rowMessages (row).length > 0 && (
|
||||
<div className="mt-2 md:max-w-3xl">
|
||||
<FieldError messages={rowMessages (row)}/>
|
||||
</div>)}
|
||||
</div>))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-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>登録対象 {submittable.length} 件</span>
|
||||
<PostImportStatusBadge value="error"/>
|
||||
<span>要修正 {counts.invalid} 件</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate ('/posts/import')}>
|
||||
URL リスト入力へ戻る
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={loading || submittable.length === 0}>
|
||||
有効な投稿を一括登録
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PostImportRowDialog
|
||||
open={editingRow != null}
|
||||
row={editingRow}
|
||||
saving={savingRow != null}
|
||||
onOpenChange={open => {
|
||||
if (!(open))
|
||||
setSearchParams ({ })
|
||||
}}
|
||||
onSave={saveDraft}/>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
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
|
||||
新しい課題から参照
ユーザをブロックする