436 行
13 KiB
TypeScript
436 行
13 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { Helmet } from 'react-helmet-async'
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
import FieldError from '@/components/common/FieldError'
|
|
import Form from '@/components/common/Form'
|
|
import FormField from '@/components/common/FormField'
|
|
import PageTitle from '@/components/common/PageTitle'
|
|
import TextArea from '@/components/common/TextArea'
|
|
import MainArea from '@/components/layout/MainArea'
|
|
import { Button } from '@/components/ui/button'
|
|
import { toast } from '@/components/ui/use-toast'
|
|
import { SITE_TITLE } from '@/config'
|
|
import { apiGet, isApiError } from '@/lib/api'
|
|
import {
|
|
appendPostNewSessionId,
|
|
serialisePostNewState,
|
|
} from '@/lib/postNewQueryState'
|
|
import { canEditContent } from '@/lib/users'
|
|
import { countImportSourceLines,
|
|
generatePostImportSessionId,
|
|
cleanupExpiredPostImportSessions,
|
|
initialisePreviewRows,
|
|
loadPostImportSession,
|
|
loadPostImportSourceDraft,
|
|
resultRepairMode,
|
|
serialisedPostImportRowsEqual,
|
|
savePostImportSession,
|
|
savePostImportSourceDraft,
|
|
validateImportSource } from '@/lib/postImportSession'
|
|
import Forbidden from '@/pages/Forbidden'
|
|
|
|
import type { FC } from 'react'
|
|
|
|
import type { PostImportRow } from '@/lib/postImportSession'
|
|
import type { User } from '@/types'
|
|
|
|
type Props = { user: User | null }
|
|
|
|
type PreviewResponse = {
|
|
url: string
|
|
title?: string
|
|
thumbnailBase?: string
|
|
originalCreatedFrom?: string
|
|
originalCreatedBefore?: string
|
|
duration?: string
|
|
videoMs?: number
|
|
tags?: string
|
|
fieldWarnings?: Record<string, string[]>
|
|
baseWarnings?: string[]
|
|
validationErrors?: Record<string, string[]>
|
|
existingPost?: {
|
|
id: number
|
|
title: string
|
|
url: string
|
|
thumbnailUrl?: string } | null }
|
|
|
|
const MAX_ROWS = 100
|
|
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/)
|
|
|
|
return rows.flatMap (row =>
|
|
(row.validationErrors.url ?? []).map (message => ({
|
|
sourceRow: row.sourceRow,
|
|
message,
|
|
url: sourceLines[row.sourceRow - 1]?.trim () ?? row.url })))
|
|
}
|
|
|
|
|
|
const previewRowFromResult = (
|
|
sourceRow: number,
|
|
result: PreviewResponse,
|
|
): PostImportRow => {
|
|
const fieldWarnings = result.fieldWarnings ?? { }
|
|
const baseWarnings = result.baseWarnings ?? []
|
|
const validationErrors = result.validationErrors ?? { }
|
|
const hasWarnings =
|
|
Object.values (fieldWarnings).some (messages => messages.length > 0)
|
|
|| baseWarnings.length > 0
|
|
const hasErrors =
|
|
Object.values (validationErrors).some (messages => messages.length > 0)
|
|
const row: PostImportRow = {
|
|
sourceRow,
|
|
url: result.url,
|
|
attributes: {
|
|
title: result.title ?? '',
|
|
thumbnailBase: result.thumbnailBase ?? '',
|
|
originalCreatedFrom: result.originalCreatedFrom ?? '',
|
|
originalCreatedBefore: result.originalCreatedBefore ?? '',
|
|
duration: result.duration ?? '',
|
|
videoMs: result.videoMs ?? '',
|
|
tags: result.tags ?? '',
|
|
parentPostIds: '' },
|
|
fieldWarnings,
|
|
baseWarnings,
|
|
validationErrors,
|
|
provenance: {
|
|
url: 'manual',
|
|
title: 'automatic',
|
|
thumbnailBase: 'automatic',
|
|
originalCreatedFrom: 'automatic',
|
|
originalCreatedBefore: 'automatic',
|
|
duration: 'automatic',
|
|
videoMs: 'automatic',
|
|
tags: 'automatic',
|
|
parentPostIds: 'automatic' },
|
|
tagSources: {
|
|
automatic: result.tags ?? '',
|
|
manual: '' },
|
|
status:
|
|
hasErrors
|
|
? 'error'
|
|
: hasWarnings
|
|
? 'warning'
|
|
: 'ready',
|
|
skipReason: result.existingPost?.id != null ? 'existing' : undefined,
|
|
existingPostId: result.existingPost?.id,
|
|
existingPost: result.existingPost ?? undefined,
|
|
resetSnapshot: {
|
|
url: result.url,
|
|
attributes: {
|
|
title: result.title ?? '',
|
|
thumbnailBase: result.thumbnailBase ?? '',
|
|
originalCreatedFrom: result.originalCreatedFrom ?? '',
|
|
originalCreatedBefore: result.originalCreatedBefore ?? '',
|
|
duration: result.duration ?? '',
|
|
videoMs: result.videoMs ?? '',
|
|
tags: result.tags ?? '',
|
|
parentPostIds: '' },
|
|
provenance: {
|
|
url: 'manual',
|
|
title: 'automatic',
|
|
thumbnailBase: 'automatic',
|
|
originalCreatedFrom: 'automatic',
|
|
originalCreatedBefore: 'automatic',
|
|
duration: 'automatic',
|
|
videoMs: 'automatic',
|
|
tags: 'automatic',
|
|
parentPostIds: 'automatic' },
|
|
tagSources: {
|
|
automatic: result.tags ?? '',
|
|
manual: '' },
|
|
fieldWarnings,
|
|
baseWarnings } }
|
|
return row
|
|
}
|
|
|
|
|
|
const fetchPreviewRows = async (
|
|
rows: Array<{ sourceRow: number
|
|
url: string }>,
|
|
signal: AbortSignal,
|
|
): Promise<{
|
|
rows: PostImportRow[]
|
|
issues: Array<{ sourceRow: number
|
|
message: string
|
|
url: string }>
|
|
}> => {
|
|
const results: Array<PostImportRow | null> = new Array (rows.length).fill (null)
|
|
const issues: Array<{ sourceRow: number
|
|
message: string
|
|
url: string }> = []
|
|
let nextIndex = 0
|
|
|
|
const previewIssues = (
|
|
sourceRow: number,
|
|
url: string,
|
|
requestError: unknown,
|
|
): Array<{ sourceRow: number
|
|
message: string
|
|
url: string }> => {
|
|
if (!(isApiError<{
|
|
message?: string
|
|
errors?: Record<string, string[]>
|
|
baseErrors?: string[]
|
|
}> (requestError)))
|
|
{
|
|
return [{
|
|
sourceRow,
|
|
message: '入力を確認してください.',
|
|
url }]
|
|
}
|
|
|
|
const fieldMessages = Object.values (requestError.response?.data?.errors ?? { }).flat ()
|
|
const baseMessages = requestError.response?.data?.baseErrors ?? []
|
|
const messages = [...new Set ([...fieldMessages, ...baseMessages])]
|
|
return (messages.length > 0 ? messages : [requestError.response?.data?.message ?? '入力を確認してください.'])
|
|
.map (message => ({
|
|
sourceRow,
|
|
message,
|
|
url }))
|
|
}
|
|
|
|
const worker = async () => {
|
|
while (nextIndex < rows.length)
|
|
{
|
|
const index = nextIndex
|
|
++nextIndex
|
|
const row = rows[index]
|
|
try
|
|
{
|
|
const result = await apiGet<PreviewResponse> ('/posts/preview', {
|
|
params: { url: row.url },
|
|
signal })
|
|
results[index] = previewRowFromResult (row.sourceRow, result)
|
|
}
|
|
catch (requestError)
|
|
{
|
|
if (signal.aborted)
|
|
return
|
|
issues.push (...previewIssues (row.sourceRow, row.url, requestError))
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all (
|
|
Array.from ({ length: Math.min (4, rows.length) }, () => worker ()))
|
|
return {
|
|
rows: results.filter ((row): row is PostImportRow => row != null),
|
|
issues }
|
|
}
|
|
|
|
|
|
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 previewController = useRef<AbortController | null> (null)
|
|
|
|
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 (value => value != null)
|
|
.join (' ')
|
|
|
|
useEffect (() => {
|
|
cleanupExpiredPostImportSessions (message =>
|
|
toast ({ title: '保存済みデータを整理できませんでした', description: message }))
|
|
|
|
const draft = loadPostImportSourceDraft (message =>
|
|
toast ({ title: '保存済み入力を復元できませんでした', description: message }))
|
|
|
|
if (!(editedRef.current))
|
|
{
|
|
setSource (current =>
|
|
current === ''
|
|
? draft.source
|
|
: current)
|
|
}
|
|
}, [])
|
|
|
|
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])
|
|
|
|
useEffect (() => () => {
|
|
previewController.current?.abort ()
|
|
}, [])
|
|
|
|
const preview = async () => {
|
|
if (lineCount === 0)
|
|
{
|
|
setSourceIssues ([])
|
|
setSourceError ('URL を入力してください.')
|
|
return
|
|
}
|
|
|
|
const issues = validateImportSource (source)
|
|
if (issues.length > 0)
|
|
{
|
|
setSourceIssues (issues)
|
|
setSourceError (null)
|
|
return
|
|
}
|
|
|
|
setLoading (true)
|
|
setSourceIssues ([])
|
|
setSourceError (null)
|
|
try
|
|
{
|
|
previewController.current?.abort ()
|
|
const controller = new AbortController ()
|
|
previewController.current = controller
|
|
const previewed = await fetchPreviewRows (
|
|
source
|
|
.split (/\r\n|\n|\r/)
|
|
.map ((line, index) => ({
|
|
sourceRow: index + 1,
|
|
url: line.trim () }))
|
|
.filter (row => row.url !== ''),
|
|
controller.signal)
|
|
if (previewed.issues.length > 0)
|
|
{
|
|
setSourceIssues (previewed.issues)
|
|
return
|
|
}
|
|
const urlIssues = urlIssuesFromRows (previewed.rows, source)
|
|
const duplicateIssues = Object.entries (
|
|
previewed.rows.reduce<Record<string, PostImportRow[]>> ((acc, row) => {
|
|
acc[row.url] ||= []
|
|
acc[row.url].push (row)
|
|
return acc
|
|
}, { }))
|
|
.flatMap (([, rows]) =>
|
|
rows.length < 2
|
|
? []
|
|
: rows.map (row => ({
|
|
sourceRow: row.sourceRow,
|
|
message: 'URL が重複しています.',
|
|
url: row.url })))
|
|
if (urlIssues.length > 0 || duplicateIssues.length > 0)
|
|
{
|
|
setSourceIssues ([...urlIssues, ...duplicateIssues])
|
|
return
|
|
}
|
|
const nextRows = initialisePreviewRows (previewed.rows)
|
|
const serialised = await serialisePostNewState (nextRows)
|
|
if (serialised == null)
|
|
return
|
|
|
|
const sessionId = generatePostImportSessionId ()
|
|
const session = {
|
|
source,
|
|
rows: nextRows,
|
|
repairMode: resultRepairMode (nextRows) }
|
|
const saved = savePostImportSession (sessionId, session)
|
|
const sessionLoaded = loadPostImportSession (sessionId)
|
|
if (!(serialised.complete)
|
|
&& (!(saved))
|
|
&& !(serialisedPostImportRowsEqual (sessionLoaded?.rows ?? [], nextRows)))
|
|
return
|
|
if (saved && !(serialisedPostImportRowsEqual (sessionLoaded?.rows ?? [], nextRows)))
|
|
return
|
|
|
|
navigate (appendPostNewSessionId (serialised.path, sessionId))
|
|
}
|
|
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}
|
|
invalid={messages.length > 0 || sourceIssues.length > 0}
|
|
aria-describedby={sourceDescribedBy || undefined}
|
|
className="h-80 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>
|
|
<Button
|
|
type="button"
|
|
className="shrink-0"
|
|
onClick={preview}
|
|
disabled={loading}>
|
|
次へ
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
</MainArea>)
|
|
}
|
|
|
|
export default PostImportSourcePage
|