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 baseWarnings?: string[] validationErrors?: Record 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 = 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 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 ('/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 = ({ user }) => { const editable = canEditContent (user) const navigate = useNavigate () const [source, setSource] = useState ('') const [loading, setLoading] = useState (false) const [sourceIssues, setSourceIssues] = useState> ([]) const [sourceError, setSourceError] = useState (null) const saveTimer = useRef (null) const editedRef = useRef (false) const previewController = useRef (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> ((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 return ( {`広場に投稿を追加 | ${ SITE_TITLE }`}
広場に投稿を追加 {() => (