このコミットが含まれているのは:
2026-07-12 14:03:05 +09:00
コミット aa96ec95d1
10個のファイルの変更1469行の追加639行の削除
+253
ファイルの表示
@@ -0,0 +1,253 @@
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 { 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,
retryImportRow,
savePostImportSession,
type PostImportResultRow,
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 }
const resultToneClass = (status: string | undefined): string[] => {
switch (status)
{
case 'created':
return [
'border-sky-200 bg-sky-50',
'dark:border-sky-900 dark:bg-sky-950/30']
case 'skipped':
return [
'border-stone-200 bg-stone-50',
'dark:border-stone-800 dark:bg-stone-900/60']
case 'failed':
return [
'border-rose-200 bg-rose-50',
'dark:border-rose-900 dark:bg-rose-950/30']
default:
return [
'border-border bg-white',
'dark:border-neutral-700 dark:bg-neutral-900']
}
}
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))
return
const loaded = loadPostImportSession (sessionId)
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
useEffect (() => {
if (!(sessionId) || !(session))
return
savePostImportSession (sessionId, session)
}, [session, sessionId])
const counts = useMemo (() => ({
created: session?.rows.filter (_1 => _1.importStatus === 'created').length ?? 0,
skipped: session?.rows.filter (_1 => _1.importStatus === 'skipped').length ?? 0,
failed: session?.rows.filter (_1 => _1.importStatus === 'failed').length ?? 0 }), [session])
const retry = async (sourceRow: number) => {
if (!(session))
return
setLoadingRow (sourceRow)
try
{
const pendingRows = retryImportRow (session.rows, sourceRow)
setSession ({ ...session, rows: pendingRows })
const target = pendingRows.find (_1 => _1.sourceRow === sourceRow)
if (target == null)
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 }],
existing: session.existing })
setSession (current =>
current
? { ...current, rows: mergeImportResults (current.rows, result.rows) }
: current)
}
catch
{
setSession (session)
toast ({ title: '再試行に失敗しました' })
}
finally
{
setLoadingRow (null)
}
}
const openRepair = (sourceRow: number) => {
const nextSession = { ...session, repairMode: 'failed' as const }
setSession (nextSession)
if (sessionId)
savePostImportSession (sessionId, nextSession)
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
}
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-5xl 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 gap-2">
<SummaryChip label="登録成功" value={counts.created} badge="created"/>
<SummaryChip label="スキップ" value={counts.skipped} badge="skipped"/>
<SummaryChip label="失敗" value={counts.failed} badge="failed"/>
</div>
</div>
<div className="space-y-3">
{session.rows.map (row => (
<div
key={row.sourceRow}
className={[
'rounded-lg border p-4',
...resultToneClass (row.importStatus)].join (' ')}>
<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>
<PostImportStatusBadge value={row.importStatus ?? 'pending'}/>
</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={Object.values (row.importErrors ?? { }).flat ()}/>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{row.createdPostId && (
<Button type="button" variant="outline" asChild>
<PrefetchLink to={`/posts/${ row.createdPostId }`}>
稿
</PrefetchLink>
</Button>)}
{row.importStatus === 'failed' && (
<>
<Button
type="button"
variant="outline"
onClick={() => openRepair (row.sourceRow)}>
</Button>
<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)
savePostImportSession (sessionId, nextSession)
navigate (`/posts/import/${ sessionId }/review`)
}}>
</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate ('/posts/import')}>
URL
</Button>
</div>
</div>
</MainArea>)
}
const SummaryChip = (
{ label, value, badge }: {
label: string
value: number
badge: 'created' | 'skipped' | 'failed' },
) => (
<div className="flex items-center gap-2 rounded-full border border-border
bg-background px-3 py-1 text-sm">
<PostImportStatusBadge value={badge}/>
<span>{label} {value}</span>
</div>)
export default PostImportResultPage