このコミットが含まれているのは:
@@ -20,12 +20,12 @@ import { countImportSourceLines,
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSession,
|
||||
savePostImportSourceDraft,
|
||||
validateImportSource,
|
||||
type PostImportRow } from '@/lib/postImportSession'
|
||||
validateImportSource } from '@/lib/postImportSession'
|
||||
import Forbidden from '@/pages/Forbidden'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSeession'
|
||||
import type { User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
@@ -35,177 +35,176 @@ const SOURCE_ERROR_ID = 'post-import-source-error'
|
||||
const SOURCE_ISSUES_ID = 'post-import-source-issues'
|
||||
|
||||
const urlIssuesFromRows = (rows: PostImportRow[], source: string) => {
|
||||
const sourceLines = source.split (/\r\n|\n|\r/)
|
||||
const sourceLines = source.split (/\r\n|\n|\r/)
|
||||
|
||||
return rows.flatMap (row =>
|
||||
(row.validationErrors.url ?? []).map (message => ({
|
||||
sourceRow: row.sourceRow,
|
||||
message,
|
||||
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
|
||||
return rows.flatMap (row =>
|
||||
(row.validationErrors.url ?? []).map (message => ({
|
||||
sourceRow: row.sourceRow,
|
||||
message,
|
||||
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
|
||||
}
|
||||
|
||||
|
||||
const PostImportSourcePage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const navigate = useNavigate ()
|
||||
const [source, setSource] = useState ('')
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
|
||||
const [sourceError, setSourceError] = useState<string | null> (null)
|
||||
const saveTimer = useRef<number | null> (null)
|
||||
const editedRef = useRef (false)
|
||||
const editable = canEditContent (user)
|
||||
const navigate = useNavigate ()
|
||||
const [source, setSource] = useState ('')
|
||||
const [loading, setLoading] = useState (false)
|
||||
const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
|
||||
const [sourceError, setSourceError] = useState<string | null> (null)
|
||||
const saveTimer = useRef<number | null> (null)
|
||||
const editedRef = useRef (false)
|
||||
|
||||
const lineCount = countImportSourceLines (source)
|
||||
const messages = sourceError != null ? [sourceError] : []
|
||||
const sourceDescribedBy = [
|
||||
sourceError != null ? SOURCE_ERROR_ID : null,
|
||||
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
|
||||
.filter (_1 => _1 != null)
|
||||
.join (' ')
|
||||
const lineCount = countImportSourceLines (source)
|
||||
const messages = sourceError != null ? [sourceError] : []
|
||||
const sourceDescribedBy =
|
||||
[sourceError != null ? SOURCE_ERROR_ID : null,
|
||||
sourceIssues.length > 0 ? SOURCE_ISSUES_ID : null]
|
||||
.filter (_1 => _1 != null)
|
||||
.join (' ')
|
||||
|
||||
useEffect (() => {
|
||||
cleanupExpiredPostImportSessions (message =>
|
||||
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
|
||||
useEffect (() => {
|
||||
cleanupExpiredPostImportSessions (message =>
|
||||
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
|
||||
|
||||
const draft = loadPostImportSourceDraft (message =>
|
||||
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
||||
if (!(editedRef.current))
|
||||
{
|
||||
setSource (current => current === '' ? draft.source : current)
|
||||
}
|
||||
}, [])
|
||||
const draft = loadPostImportSourceDraft (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])
|
||||
if (!(editedRef.current))
|
||||
setSource (current => current === '' ? draft.source : current)
|
||||
}, [])
|
||||
|
||||
const preview = async () => {
|
||||
if (lineCount === 0)
|
||||
{
|
||||
setSourceIssues ([])
|
||||
setSourceError ('URL を入力してください.')
|
||||
return
|
||||
}
|
||||
useEffect (() => {
|
||||
if (saveTimer.current != null)
|
||||
window.clearTimeout (saveTimer.current)
|
||||
|
||||
const issues = validateImportSource (source)
|
||||
if (issues.length > 0)
|
||||
{
|
||||
setSourceIssues (issues)
|
||||
setSourceError (null)
|
||||
return
|
||||
}
|
||||
saveTimer.current = window.setTimeout (() => {
|
||||
savePostImportSourceDraft (source, message =>
|
||||
toast ({ title: '入力内容を保存できませんでした', description: message }))
|
||||
}, 300)
|
||||
|
||||
setLoading (true)
|
||||
setSourceIssues ([])
|
||||
setSourceError (null)
|
||||
try
|
||||
{
|
||||
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
|
||||
source })
|
||||
const urlIssues = urlIssuesFromRows (data.rows, source)
|
||||
if (urlIssues.length > 0)
|
||||
{
|
||||
setSourceIssues (urlIssues)
|
||||
return
|
||||
}
|
||||
const sessionId = createPostImportSessionId ()
|
||||
const saved = savePostImportSession (sessionId, {
|
||||
source,
|
||||
rows: initialisePreviewRows (data.rows),
|
||||
repairMode: 'all' }, message =>
|
||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (!(saved))
|
||||
return
|
||||
navigate (`/posts/import/${ sessionId }/review`)
|
||||
}
|
||||
catch (requestError)
|
||||
{
|
||||
const message =
|
||||
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
|
||||
? requestError.response?.data?.message
|
||||
?? requestError.response?.data?.baseErrors?.[0]
|
||||
: undefined
|
||||
setSourceError (message ?? '入力を確認してください.')
|
||||
toast ({
|
||||
title: '投稿情報の取得に失敗しました',
|
||||
description: message ?? '入力を確認してください.' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
return () => {
|
||||
if (saveTimer.current != null)
|
||||
window.clearTimeout (saveTimer.current)
|
||||
}
|
||||
}, [source])
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
const preview = async () => {
|
||||
if (lineCount === 0)
|
||||
{
|
||||
setSourceIssues ([])
|
||||
setSourceError ('URL を入力してください.')
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
const issues = validateImportSource (source)
|
||||
if (issues.length > 0)
|
||||
{
|
||||
setSourceIssues (issues)
|
||||
setSourceError (null)
|
||||
return
|
||||
}
|
||||
|
||||
<Form className="max-w-4xl">
|
||||
<PageTitle>投稿インポート</PageTitle>
|
||||
<FormField label="URL リスト">
|
||||
{() => (
|
||||
<textarea
|
||||
value={source}
|
||||
rows={14}
|
||||
aria-invalid={messages.length > 0 || sourceIssues.length > 0}
|
||||
aria-describedby={sourceDescribedBy || undefined}
|
||||
className={inputClass (
|
||||
messages.length > 0 || sourceIssues.length > 0,
|
||||
'font-mono text-sm')}
|
||||
onBlur={() => {
|
||||
savePostImportSourceDraft (source, message =>
|
||||
toast ({
|
||||
title: '入力内容を保存できませんでした',
|
||||
description: message }))
|
||||
}}
|
||||
onChange={ev => {
|
||||
editedRef.current = true
|
||||
setSource (ev.target.value)
|
||||
setSourceError (null)
|
||||
setSourceIssues ([])
|
||||
}}/>)}
|
||||
</FormField>
|
||||
<div id={messages.length > 0 ? SOURCE_ERROR_ID : undefined}>
|
||||
<FieldError messages={messages}/>
|
||||
setLoading (true)
|
||||
setSourceIssues ([])
|
||||
setSourceError (null)
|
||||
try
|
||||
{
|
||||
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { source })
|
||||
const urlIssues = urlIssuesFromRows (data.rows, source)
|
||||
if (urlIssues.length > 0)
|
||||
{
|
||||
setSourceIssues (urlIssues)
|
||||
return
|
||||
}
|
||||
const sessionId = createPostImportSessionId ()
|
||||
const saved = savePostImportSession (
|
||||
sessionId,
|
||||
{ source,
|
||||
rows: initialisePreviewRows (data.rows),
|
||||
repairMode: 'all' },
|
||||
message => toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||
if (!(saved))
|
||||
return
|
||||
navigate (`/posts/import/${ sessionId }/review`)
|
||||
}
|
||||
catch (requestError)
|
||||
{
|
||||
const message =
|
||||
isApiError<{ message?: string, baseErrors?: string[] }> (requestError)
|
||||
? (requestError.response?.data?.message
|
||||
?? requestError.response?.data?.baseErrors?.[0])
|
||||
: undefined
|
||||
setSourceError (message ?? '入力を確認してください.')
|
||||
toast ({ title: '投稿情報の取得に失敗しました',
|
||||
description: message ?? '入力を確認してください.' })
|
||||
}
|
||||
finally
|
||||
{
|
||||
setLoading (false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!(editable))
|
||||
return <Forbidden/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`投稿インポート | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<Form className="max-w-4xl">
|
||||
<PageTitle>投稿インポート</PageTitle>
|
||||
<FormField label="URL リスト">
|
||||
{() => (
|
||||
<textarea
|
||||
value={source}
|
||||
rows={14}
|
||||
aria-invalid={messages.length > 0 || sourceIssues.length > 0}
|
||||
aria-describedby={sourceDescribedBy || undefined}
|
||||
className={inputClass (
|
||||
messages.length > 0 || sourceIssues.length > 0,
|
||||
'font-mono text-sm')}
|
||||
onBlur={() => {
|
||||
savePostImportSourceDraft (source, message =>
|
||||
toast ({ title: '入力内容を保存できませんでした',
|
||||
description: message }))
|
||||
}}
|
||||
onChange={ev => {
|
||||
editedRef.current = true
|
||||
setSource (ev.target.value)
|
||||
setSourceError (null)
|
||||
setSourceIssues ([])
|
||||
}}/>)}
|
||||
</FormField>
|
||||
<div id={messages.length > 0 ? SOURCE_ERROR_ID : undefined}>
|
||||
<FieldError messages={messages}/>
|
||||
</div>
|
||||
<ul
|
||||
id={SOURCE_ISSUES_ID}
|
||||
className="space-y-2 text-sm text-red-700 dark:text-red-300">
|
||||
{sourceIssues.map (issue => (
|
||||
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
|
||||
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
||||
<div className="break-all font-mono text-xs">{issue.url}</div>
|
||||
</li>))}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
入力行数 {lineCount} / {MAX_ROWS}
|
||||
</div>
|
||||
<ul
|
||||
id={SOURCE_ISSUES_ID}
|
||||
className="space-y-2 text-sm text-red-700 dark:text-red-300">
|
||||
{sourceIssues.map (issue => (
|
||||
<li key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}>
|
||||
<div>{issue.sourceRow} 行目: {issue.message}</div>
|
||||
<div className="break-all font-mono text-xs">{issue.url}</div>
|
||||
</li>))}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
入力行数 {lineCount} / {MAX_ROWS}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0"
|
||||
onClick={preview}
|
||||
disabled={loading}>
|
||||
次へ
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</MainArea>)
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0"
|
||||
onClick={preview}
|
||||
disabled={loading}>
|
||||
次へ
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
export default PostImportSourcePage
|
||||
|
||||
新しい課題から参照
ユーザをブロックする