ファイル
btrc-hub/frontend/src/pages/posts/PostImportSourcePage.tsx
T
2026-07-18 00:31:02 +09:00

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