このコミットが含まれているのは:
2026-07-13 00:58:02 +09:00
コミット ac82adc6b3
12個のファイルの変更789行の追加256行の削除
+29 -8
ファイルの表示
@@ -13,7 +13,8 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { loadPostImportSession,
import { clearPostImportSourceDraft,
loadPostImportSession,
mergeImportResults,
retryImportRow,
savePostImportSession,
@@ -63,7 +64,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
if (!(sessionId))
return
const loaded = loadPostImportSession (sessionId)
const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
@@ -72,7 +74,8 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
if (!(sessionId) || !(session))
return
savePostImportSession (sessionId, session)
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
const counts = useMemo (() => ({
@@ -104,8 +107,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
attributes: target.attributes,
provenance: target.provenance,
tagSources: target.tagSources,
metadataUrl: target.metadataUrl }],
existing: session.existing })
metadataUrl: target.metadataUrl }] })
setSession (current =>
current
? { ...current, rows: mergeImportResults (current.rows, result.rows) }
@@ -126,7 +128,12 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
const nextSession = { ...session, repairMode: 'failed' as const }
setSession (nextSession)
if (sessionId)
savePostImportSession (sessionId, nextSession)
{
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
}
navigate (`/posts/import/${ sessionId }/review?edit=${ sourceRow }`)
}
@@ -185,6 +192,13 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{row.url}
</div>
{row.createdPostId && (
<PrefetchLink
to={`/posts/${ row.createdPostId }`}
className="inline-block text-xs text-sky-700 underline
dark:text-sky-300">
稿 #{row.createdPostId}
</PrefetchLink>)}
<FieldError messages={Object.values (row.importErrors ?? { }).flat ()}/>
</div>
@@ -222,7 +236,10 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
onClick={() => {
const nextSession = { ...session, repairMode: 'all' as const }
setSession (nextSession)
savePostImportSession (sessionId, nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
}}>
@@ -230,7 +247,11 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
<Button
type="button"
variant="outline"
onClick={() => navigate ('/posts/import')}>
onClick={() => {
clearPostImportSourceDraft (message =>
toast ({ title: '入力内容を削除できませんでした', description: message }))
navigate ('/posts/import')
}}>
URL
</Button>
</div>
+21 -61
ファイルの表示
@@ -19,7 +19,6 @@ import { loadPostImportSession,
mergeImportResults,
mergeValidatedImportRows,
reviewSummaryCounts,
rowMessages,
savePostImportSession,
submittableImportRows,
type PostImportResultRow,
@@ -59,7 +58,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (!(sessionId))
return
const loaded = loadPostImportSession (sessionId)
const loaded = loadPostImportSession (sessionId, message =>
toast ({ title: '取込状態を復元できませんでした', description: message }))
setSession (loaded)
setMissing (loaded == null)
}, [sessionId])
@@ -68,7 +68,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
if (!(sessionId) || !(session))
return
savePostImportSession (sessionId, session)
savePostImportSession (sessionId, session, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
}, [session, sessionId])
const rows = session?.rows ?? []
@@ -160,8 +161,15 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
try
{
const validated = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/validate', {
rows: nextRows.filter (row => row.importStatus !== 'created'),
existing: session.existing,
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 ({ })
@@ -178,33 +186,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
}
}
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
@@ -223,12 +204,14 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
attributes: row.attributes,
provenance: row.provenance,
tagSources: row.tagSources,
metadataUrl: row.metadataUrl })),
existing: session.existing })
metadataUrl: row.metadataUrl })) })
const nextRows = mergeImportResults (session.rows, result.rows)
const nextSession = { ...session, rows: nextRows, repairMode: 'all' as const }
setSession (nextSession)
savePostImportSession (sessionId, nextSession)
const saved = savePostImportSession (sessionId, nextSession, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/result`)
}
catch
@@ -264,7 +247,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="mx-auto max-w-6xl space-y-4 p-4 pb-28">
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4 pb-8">
<PageTitle>稿</PageTitle>
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
@@ -279,26 +262,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
</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">
<div className="flex-1 space-y-3 pb-24">
{reviewRows.map (row => (
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary
@@ -306,16 +270,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
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
className="sticky 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">
+60 -37
ファイルの表示
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
@@ -13,11 +13,12 @@ import { apiPost, isApiError } from '@/lib/api'
import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils'
import { countImportSourceLines,
countImportSourceValidLines,
cleanupExpiredPostImportSessions,
createPostImportSessionId,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft,
validateImportSource,
type PostImportRow } from '@/lib/postImportSession'
import Forbidden from '@/pages/Forbidden'
@@ -33,38 +34,58 @@ const MAX_ROWS = 100
const PostImportSourcePage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const navigate = useNavigate ()
const draft = useMemo (() => loadPostImportSourceDraft (), [])
const draft = useMemo (() => loadPostImportSourceDraft (message =>
toast ({ title: '保存済み入力を復元できませんでした', description: message })), [])
const [source, setSource] = useState (draft.source)
const [existing, setExisting] = useState<'skip' | 'error'> (draft.existing)
const [loading, setLoading] = useState (false)
const [error, setError] = useState<string | null> (null)
const [touched, setTouched] = useState (false)
const saveTimer = useRef<number | null> (null)
const lineCount = countImportSourceLines (source)
const validLineCount = countImportSourceValidLines (source)
const issues = useMemo (() => validateImportSource (source), [source])
const messages = [
...(lineCount === 0 ? ['URL を入力してください.'] : []),
...(lineCount > MAX_ROWS ? [`最大 ${ MAX_ROWS } 件までです.`] : []),
...(((touched) && lineCount === 0) ? ['URL を入力してください.'] : []),
...(error ? [error] : [])]
useEffect (() => {
savePostImportSourceDraft (source, existing)
}, [existing, source])
cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
}, [])
useEffect (() => {
if (saveTimer.current != null)
window.clearTimeout (saveTimer.current)
saveTimer.current = window.setTimeout (() => {
savePostImportSourceDraft (source, message =>
toast ({ title: '入力内容を保存できませんでした', description: message }))
}, 300)
return () => {
if (saveTimer.current != null)
window.clearTimeout (saveTimer.current)
}
}, [source])
const preview = async () => {
setTouched (true)
if (lineCount === 0 || issues.length > 0)
return
setLoading (true)
setError (null)
try
{
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
source,
existing })
source })
const sessionId = createPostImportSessionId ()
savePostImportSession (sessionId, {
const saved = savePostImportSession (sessionId, {
source,
existing,
rows: data.rows,
repairMode: 'all' })
repairMode: 'all' }, message =>
toast ({ title: '取込状態を保存できませんでした', description: message }))
if (!(saved))
return
navigate (`/posts/import/${ sessionId }/review`)
}
catch (requestError)
@@ -99,48 +120,50 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700
dark:bg-neutral-900 md:p-6">
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-lg font-semibold">URL </h2>
<p className="text-sm text-neutral-600 dark:text-neutral-300">
URL
</p>
</div>
<FormField label="URL リスト">
{() => (
<textarea
value={source}
rows={14}
className={inputClass (
messages.length > 0,
messages.length > 0 || issues.length > 0,
'font-mono text-sm',
)}
onChange={ev => setSource (ev.target.value)}/>)}
onBlur={() => {
setTouched (true)
savePostImportSourceDraft (source, message =>
toast ({
title: '入力内容を保存できませんでした',
description: message }))
}}
onChange={ev => {
setSource (ev.target.value)
setError (null)
}}/>)}
</FormField>
<FieldError messages={messages}/>
<div className="space-y-2">
{issues.map (issue => (
<div
key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}
className="rounded-md border border-red-300 bg-red-50 p-3 text-sm
text-red-700 dark:border-red-900 dark:bg-red-950
dark:text-red-200">
<div>{issue.sourceRow} : {issue.message}</div>
<div className="mt-1 break-all font-mono text-xs">{issue.url}</div>
</div>))}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 text-sm
text-neutral-600 dark:text-neutral-300">
<div>: {validLineCount}</div>
<div> {MAX_ROWS} </div>
<div> {lineCount} / {MAX_ROWS}</div>
</div>
<label className="block space-y-1">
<span className="text-sm font-medium">稿</span>
<select
value={existing}
onChange={ev => setExisting (ev.target.value as typeof existing)}
className={inputClass (false, 'w-full md:w-auto')}>
<option value="skip"></option>
<option value="error"></option>
</select>
</label>
<div className="flex justify-end">
<Button
type="button"
onClick={preview}
disabled={loading || lineCount === 0 || lineCount > MAX_ROWS}>
disabled={loading || lineCount === 0 || issues.length > 0}>
稿
</Button>
</div>