f1181e8510
Reviewed-on: #413 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
1190 行
36 KiB
TypeScript
1190 行
36 KiB
TypeScript
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Helmet } from 'react-helmet-async'
|
|
import { ChevronRight } from 'lucide-react'
|
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
|
|
import PageTitle from '@/components/common/PageTitle'
|
|
import MainArea from '@/components/layout/MainArea'
|
|
import PrefetchLink from '@/components/PrefetchLink'
|
|
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
|
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
|
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
|
import { Button } from '@/components/ui/button'
|
|
import { toast } from '@/components/ui/use-toast'
|
|
import { SITE_TITLE } from '@/config'
|
|
import { apiGet, apiPost, isApiError } from '@/lib/api'
|
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
|
import { parsePostNewReviewUrls } from '@/lib/postNewQueryState'
|
|
import { validateImportSource } from '@/lib/postImportSourceValidation'
|
|
import {
|
|
applyThumbnailWarning,
|
|
buildNextEditedRow,
|
|
canEditReviewRow,
|
|
canRetryResultRow,
|
|
compactMessageRecord,
|
|
hasErrorMessages,
|
|
hasThumbnailBaseValue,
|
|
isCompletedReviewRow,
|
|
isExistingSkipRow,
|
|
isManualSkipRow,
|
|
isNonRecoverableFailedRow,
|
|
mergeImportResults,
|
|
processableImportRows,
|
|
replaceImportRow,
|
|
resultRepairMode,
|
|
resultRowMessages,
|
|
reviewSummaryCounts,
|
|
retryImportRow,
|
|
} from '@/lib/postImportRows'
|
|
import { clearPostImportSourceDraft } from '@/lib/postImportStorage'
|
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
|
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
|
import { cn } from '@/lib/utils'
|
|
import { canEditContent } from '@/lib/users'
|
|
import Forbidden from '@/pages/Forbidden'
|
|
|
|
import type { FC } from 'react'
|
|
|
|
import type {
|
|
PostImportDisplayTag,
|
|
PostImportEditableDraft,
|
|
PostImportResetSnapshot,
|
|
PostImportResultRow,
|
|
PostImportRow,
|
|
} from '@/lib/postImportTypes'
|
|
import type { User } from '@/types'
|
|
|
|
type Props = { user: User | null }
|
|
|
|
type PostMetadataResponse = {
|
|
url: string
|
|
title?: string
|
|
thumbnailBase?: string
|
|
originalCreatedFrom?: string
|
|
originalCreatedBefore?: string
|
|
parentPostIds?: string
|
|
duration?: string
|
|
videoMs?: number
|
|
tags?: string
|
|
displayTags?: PostImportDisplayTag[]
|
|
fieldWarnings?: Record<string, string[]>
|
|
baseWarnings?: string[]
|
|
validationErrors?: Record<string, string[]>
|
|
existingPostId?: number
|
|
existingPost?: {
|
|
id: number
|
|
title: string
|
|
url: string
|
|
thumbnail?: string | null
|
|
thumbnailBase?: string | null } | null }
|
|
|
|
type BulkApiRow = {
|
|
status: 'created' | 'skipped' | 'failed'
|
|
post?: { id: number }
|
|
existingPostId?: number
|
|
existingPost?: {
|
|
id: number
|
|
title: string
|
|
url: string
|
|
thumbnail?: string | null
|
|
thumbnailBase?: string | null } | null
|
|
fieldWarnings?: Record<string, string[]>
|
|
baseWarnings?: string[]
|
|
errors?: Record<string, string[]>
|
|
baseErrors?: string[]
|
|
recoverable?: boolean }
|
|
|
|
type ExistingSkippedRowsProps = {
|
|
id: string
|
|
rows: PostImportRow[] }
|
|
|
|
const DUPLICATE_URL_MESSAGE = 'URL が重複しています.'
|
|
const BULK_PROCESSING_FAILED_MESSAGE = '登録中にエラーが発生しました.'
|
|
|
|
const emptyAttributes = () => ({
|
|
title: '',
|
|
thumbnailBase: '',
|
|
originalCreatedFrom: '',
|
|
originalCreatedBefore: '',
|
|
duration: '',
|
|
tags: '',
|
|
parentPostIds: '' })
|
|
|
|
|
|
const emptyProvenance = () => ({
|
|
url: 'manual',
|
|
title: 'automatic',
|
|
thumbnailBase: 'automatic',
|
|
originalCreatedFrom: 'automatic',
|
|
originalCreatedBefore: 'automatic',
|
|
duration: 'automatic',
|
|
tags: 'automatic',
|
|
parentPostIds: 'automatic' }) as const
|
|
|
|
|
|
const emptyTagSources = () => ({
|
|
automatic: '',
|
|
manual: '' })
|
|
|
|
|
|
const cloneDisplayTags = (
|
|
tags: PostImportDisplayTag[] | undefined,
|
|
): PostImportDisplayTag[] =>
|
|
tags?.map (tag => ({
|
|
name: tag.name,
|
|
category: tag.category,
|
|
sectionLiterals:
|
|
tag.sectionLiterals == null
|
|
? undefined
|
|
: [...tag.sectionLiterals] })) ?? []
|
|
|
|
|
|
const cloneMessageRecord = (
|
|
record: Record<string, string[]>,
|
|
): Record<string, string[]> =>
|
|
Object.fromEntries (
|
|
Object.entries (record).map (([key, messages]) => [key, [...messages]]))
|
|
|
|
|
|
const buildEmptyResetSnapshot = (url: string): PostImportResetSnapshot => ({
|
|
url,
|
|
attributes: emptyAttributes (),
|
|
displayTags: [],
|
|
provenance: emptyProvenance (),
|
|
tagSources: emptyTagSources (),
|
|
fieldWarnings: { },
|
|
baseWarnings: [] })
|
|
|
|
|
|
const buildInitialRows = (urls: string[]): PostImportRow[] => {
|
|
const source = urls.join ('\n')
|
|
const issues = validateImportSource (source)
|
|
const issuesByRow = issues.reduce<Record<number, string[]>> ((result, issue) => {
|
|
result[issue.sourceRow] = [...(result[issue.sourceRow] ?? []), issue.message]
|
|
return result
|
|
}, { })
|
|
|
|
return applyDuplicateUrlErrors (
|
|
urls.map ((url, index) => {
|
|
const sourceRow = index + 1
|
|
const rowIssues = issuesByRow[sourceRow] ?? []
|
|
const validationErrors: Record<string, string[]> =
|
|
rowIssues.length > 0
|
|
? { url: [...rowIssues] }
|
|
: { }
|
|
return {
|
|
sourceRow,
|
|
url,
|
|
attributes: emptyAttributes (),
|
|
fieldWarnings: { },
|
|
baseWarnings: [],
|
|
validationErrors,
|
|
provenance: emptyProvenance (),
|
|
tagSources: emptyTagSources (),
|
|
status: rowIssues.length > 0 ? 'error' : 'pending',
|
|
displayTags: [],
|
|
resetSnapshot: buildEmptyResetSnapshot (url) }
|
|
}))
|
|
}
|
|
|
|
|
|
const rowMetadataPending = (row: PostImportRow): boolean =>
|
|
row.status === 'pending'
|
|
|
|
|
|
const rowOperationBusy = (
|
|
row: PostImportRow,
|
|
submitting: boolean,
|
|
busyRowIds: Set<number>,
|
|
): boolean =>
|
|
submitting
|
|
|| busyRowIds.has (row.sourceRow)
|
|
|| rowMetadataPending (row)
|
|
|
|
|
|
const rowSkipBusy = (
|
|
row: PostImportRow,
|
|
submitting: boolean,
|
|
busyRowIds: Set<number>,
|
|
): boolean =>
|
|
submitting
|
|
|| busyRowIds.has (row.sourceRow)
|
|
|| (rowMetadataPending (row) && !(isManualSkipRow (row)))
|
|
|
|
|
|
const shouldFetchMetadata = (row: PostImportRow): boolean =>
|
|
row.status === 'pending'
|
|
&& row.skipReason !== 'manual'
|
|
&& row.importStatus !== 'created'
|
|
&& row.importStatus !== 'skipped'
|
|
&& !(isNonRecoverableFailedRow (row))
|
|
&& row.metadataUrl == null
|
|
|
|
|
|
const shouldHydrateExistingPost = (row: PostImportRow): boolean =>
|
|
row.skipReason === 'existing'
|
|
&& row.existingPostId != null
|
|
&& row.existingPost == null
|
|
&& row.importStatus !== 'created'
|
|
&& row.importStatus !== 'skipped'
|
|
|
|
|
|
const shouldFetchReviewRow = (row: PostImportRow): boolean =>
|
|
shouldFetchMetadata (row) || shouldHydrateExistingPost (row)
|
|
|
|
|
|
const applyDuplicateUrlErrors = (
|
|
rows: PostImportRow[],
|
|
): PostImportRow[] => {
|
|
const counts = rows.reduce<Record<string, number>> ((result, row) => {
|
|
if (row.url !== ''
|
|
&& !(isManualSkipRow (row))
|
|
&& row.importStatus !== 'created'
|
|
&& row.importStatus !== 'skipped')
|
|
result[row.url] = (result[row.url] ?? 0) + 1
|
|
return result
|
|
}, { })
|
|
|
|
return rows.map (row => {
|
|
const urlErrors = (row.validationErrors.url ?? []).filter (
|
|
message => message !== DUPLICATE_URL_MESSAGE)
|
|
if ((counts[row.url] ?? 0) < 2)
|
|
{
|
|
const nextValidationErrors = compactMessageRecord ({
|
|
...row.validationErrors,
|
|
url: urlErrors })
|
|
const hasErrors = hasErrorMessages (nextValidationErrors)
|
|
return urlErrors.length === (row.validationErrors.url ?? []).length
|
|
? row
|
|
: {
|
|
...row,
|
|
validationErrors: nextValidationErrors,
|
|
skipReason:
|
|
row.skipReason === 'manual'
|
|
? 'manual'
|
|
: row.existingPostId != null
|
|
? 'existing'
|
|
: undefined,
|
|
status: hasErrors ? 'error' : row.status }
|
|
}
|
|
|
|
return {
|
|
...row,
|
|
skipReason: row.skipReason === 'manual' ? 'manual' : undefined,
|
|
validationErrors: compactMessageRecord ({
|
|
...row.validationErrors,
|
|
url: [...new Set ([...urlErrors, DUPLICATE_URL_MESSAGE])] }),
|
|
status: 'error' }
|
|
})
|
|
}
|
|
|
|
|
|
const buildDryRunFormData = (row: PostImportRow): FormData => {
|
|
const formData = new FormData ()
|
|
formData.append ('url', row.url)
|
|
formData.append ('title', String (row.attributes.title ?? ''))
|
|
formData.append ('thumbnail_base', String (row.attributes.thumbnailBase ?? ''))
|
|
formData.append ('tags', String (row.attributes.tags ?? ''))
|
|
formData.append ('parent_post_ids', String (row.attributes.parentPostIds ?? ''))
|
|
formData.append (
|
|
'original_created_from',
|
|
String (row.attributes.originalCreatedFrom ?? ''))
|
|
formData.append (
|
|
'original_created_before',
|
|
String (row.attributes.originalCreatedBefore ?? ''))
|
|
formData.append ('duration', String (row.attributes.duration ?? ''))
|
|
if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
|
|
formData.append ('thumbnail', row.thumbnailFile)
|
|
return formData
|
|
}
|
|
|
|
|
|
const mergeDryRunRow = (
|
|
currentRow: PostImportRow,
|
|
result: PostMetadataResponse,
|
|
): PostImportRow => {
|
|
const fieldWarnings = compactMessageRecord (result.fieldWarnings ?? { })
|
|
const hasWarnings =
|
|
Object.values (fieldWarnings).some (messages => messages.length > 0)
|
|
|| (result.baseWarnings?.length ?? 0) > 0
|
|
|
|
return applyThumbnailWarning ({
|
|
...currentRow,
|
|
url: result.url,
|
|
attributes: {
|
|
...currentRow.attributes,
|
|
title: result.title ?? '',
|
|
thumbnailBase: result.thumbnailBase ?? '',
|
|
originalCreatedFrom: result.originalCreatedFrom ?? '',
|
|
originalCreatedBefore: result.originalCreatedBefore ?? '',
|
|
duration: result.duration ?? '',
|
|
tags: result.tags ?? '',
|
|
parentPostIds: String (
|
|
result.parentPostIds ?? currentRow.attributes.parentPostIds ?? '') },
|
|
displayTags: cloneDisplayTags (result.displayTags),
|
|
fieldWarnings,
|
|
baseWarnings: result.baseWarnings ?? [],
|
|
validationErrors: { },
|
|
importErrors: undefined,
|
|
status: hasWarnings ? 'warning' : 'ready',
|
|
skipReason:
|
|
result.existingPostId != null || result.existingPost?.id != null
|
|
? 'existing'
|
|
: undefined,
|
|
existingPostId: result.existingPostId ?? result.existingPost?.id,
|
|
existingPost: result.existingPost ?? undefined,
|
|
importStatus:
|
|
currentRow.importStatus === 'created'
|
|
? 'created'
|
|
: 'pending',
|
|
recoverable: undefined })
|
|
}
|
|
|
|
|
|
const buildPreviewResetSnapshot = (
|
|
preview: PostMetadataResponse,
|
|
): PostImportRow['resetSnapshot'] => ({
|
|
url: preview.url,
|
|
attributes: {
|
|
title: preview.title ?? '',
|
|
thumbnailBase: preview.thumbnailBase ?? '',
|
|
originalCreatedFrom: preview.originalCreatedFrom ?? '',
|
|
originalCreatedBefore: preview.originalCreatedBefore ?? '',
|
|
duration: preview.duration ?? '',
|
|
tags: preview.tags ?? '',
|
|
parentPostIds: String (preview.parentPostIds ?? '') },
|
|
displayTags: cloneDisplayTags (preview.displayTags),
|
|
provenance: emptyProvenance (),
|
|
tagSources: {
|
|
automatic: preview.tags ?? '',
|
|
manual: '' },
|
|
fieldWarnings: preview.fieldWarnings ?? { },
|
|
baseWarnings: preview.baseWarnings ?? [],
|
|
metadataUrl: preview.url })
|
|
|
|
|
|
const indexedBulkResults = (
|
|
rows: PostImportRow[],
|
|
results: BulkApiRow[],
|
|
): PostImportResultRow[] =>
|
|
rows.map ((row, index) => {
|
|
const result = results[index]
|
|
const errors =
|
|
result?.baseErrors?.length
|
|
? {
|
|
...(result.errors ?? { }),
|
|
base: [...new Set ([...(result.errors?.base ?? []), ...result.baseErrors])] }
|
|
: result?.errors
|
|
if (result?.status === 'created' && result.post != null)
|
|
{
|
|
return {
|
|
sourceRow: row.sourceRow,
|
|
status: 'created',
|
|
post: result.post,
|
|
fieldWarnings: result.fieldWarnings,
|
|
baseWarnings: result.baseWarnings,
|
|
errors }
|
|
}
|
|
if (result?.status === 'skipped')
|
|
{
|
|
const existingPostId =
|
|
result.existingPostId
|
|
?? result.existingPost?.id
|
|
?? row.existingPostId
|
|
if (existingPostId == null)
|
|
{
|
|
return {
|
|
sourceRow: row.sourceRow,
|
|
status: 'failed',
|
|
fieldWarnings: result.fieldWarnings,
|
|
baseWarnings: result.baseWarnings,
|
|
errors: {
|
|
...(errors ?? { }),
|
|
base: [...new Set ([...(errors?.base ?? []),
|
|
BULK_PROCESSING_FAILED_MESSAGE])] },
|
|
recoverable: false }
|
|
}
|
|
return {
|
|
sourceRow: row.sourceRow,
|
|
status: 'skipped',
|
|
existingPostId,
|
|
existingPost: result.existingPost ?? row.existingPost,
|
|
fieldWarnings: result.fieldWarnings,
|
|
baseWarnings: result.baseWarnings,
|
|
errors }
|
|
}
|
|
return {
|
|
sourceRow: row.sourceRow,
|
|
status: 'failed',
|
|
fieldWarnings: result?.fieldWarnings,
|
|
baseWarnings: result?.baseWarnings,
|
|
errors,
|
|
recoverable: result?.recoverable }
|
|
})
|
|
|
|
|
|
const mergePreviewRow = (
|
|
currentRow: PostImportRow,
|
|
preview: PostMetadataResponse,
|
|
): PostImportRow => {
|
|
const fieldWarnings = compactMessageRecord (preview.fieldWarnings ?? { })
|
|
const baseWarnings = preview.baseWarnings ?? []
|
|
const validationErrors = compactMessageRecord (preview.validationErrors ?? { })
|
|
const metadataChanged = currentRow.metadataUrl !== preview.url
|
|
const hasWarnings =
|
|
Object.values (fieldWarnings).some (messages => messages.length > 0)
|
|
|| baseWarnings.length > 0
|
|
const hasErrors =
|
|
Object.values (validationErrors).some (messages => messages.length > 0)
|
|
const nextRow: PostImportRow = {
|
|
...currentRow,
|
|
attributes: { ...currentRow.attributes },
|
|
provenance: { ...currentRow.provenance },
|
|
displayTags: cloneDisplayTags (currentRow.displayTags),
|
|
tagSources: {
|
|
automatic: currentRow.tagSources?.automatic ?? '',
|
|
manual: currentRow.tagSources?.manual ?? '' },
|
|
url: preview.url,
|
|
fieldWarnings,
|
|
baseWarnings,
|
|
validationErrors,
|
|
existingPostId: preview.existingPostId ?? preview.existingPost?.id,
|
|
existingPost: preview.existingPost ?? undefined,
|
|
skipReason:
|
|
preview.existingPostId != null || preview.existingPost?.id != null
|
|
? 'existing'
|
|
: currentRow.skipReason === 'manual'
|
|
? 'manual'
|
|
: undefined,
|
|
metadataUrl: preview.url,
|
|
resetSnapshot:
|
|
metadataChanged
|
|
? buildPreviewResetSnapshot (preview)
|
|
: currentRow.resetSnapshot,
|
|
status:
|
|
hasErrors
|
|
? 'error'
|
|
: hasWarnings
|
|
? 'warning'
|
|
: 'ready' }
|
|
|
|
if (currentRow.provenance.title !== 'manual')
|
|
nextRow.attributes.title = preview.title ?? ''
|
|
if (currentRow.provenance.thumbnailBase !== 'manual')
|
|
nextRow.attributes.thumbnailBase = preview.thumbnailBase ?? ''
|
|
if (currentRow.provenance.originalCreatedFrom !== 'manual')
|
|
nextRow.attributes.originalCreatedFrom = preview.originalCreatedFrom ?? ''
|
|
if (currentRow.provenance.originalCreatedBefore !== 'manual')
|
|
nextRow.attributes.originalCreatedBefore = preview.originalCreatedBefore ?? ''
|
|
if (currentRow.provenance.tags !== 'manual')
|
|
{
|
|
nextRow.attributes.tags = preview.tags ?? ''
|
|
nextRow.displayTags = cloneDisplayTags (preview.displayTags)
|
|
}
|
|
nextRow.tagSources!.automatic = preview.tags ?? ''
|
|
if (currentRow.provenance.parentPostIds !== 'manual')
|
|
nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
|
|
if (currentRow.provenance.duration !== 'manual')
|
|
nextRow.attributes.duration = preview.duration ?? ''
|
|
|
|
return applyThumbnailWarning (nextRow)
|
|
}
|
|
|
|
|
|
const mergeExistingSkippedRow = (
|
|
currentRow: PostImportRow,
|
|
preview: PostMetadataResponse,
|
|
): PostImportRow =>
|
|
preview.existingPostId != null || preview.existingPost?.id != null
|
|
? {
|
|
...currentRow,
|
|
url: preview.url,
|
|
existingPostId: preview.existingPostId ?? preview.existingPost?.id,
|
|
existingPost: preview.existingPost ?? currentRow.existingPost,
|
|
skipReason: 'existing',
|
|
metadataUrl: preview.url }
|
|
: currentRow
|
|
|
|
|
|
const buildBulkFormData = (rows: PostImportRow[]): FormData => {
|
|
const formData = new FormData ()
|
|
formData.append (
|
|
'posts',
|
|
JSON.stringify (
|
|
rows.map (row => ({
|
|
url: row.url,
|
|
title: row.attributes.title ?? '',
|
|
thumbnail_base: row.attributes.thumbnailBase ?? '',
|
|
tags: row.attributes.tags ?? '',
|
|
parent_post_ids: row.attributes.parentPostIds ?? '',
|
|
original_created_from: row.attributes.originalCreatedFrom ?? '',
|
|
original_created_before: row.attributes.originalCreatedBefore ?? '',
|
|
duration: row.attributes.duration ?? '' }))))
|
|
rows.forEach ((row, index) => {
|
|
if (!(hasThumbnailBaseValue (row.attributes.thumbnailBase)) && row.thumbnailFile != null)
|
|
formData.append (`thumbnails[${ index }]`, row.thumbnailFile)
|
|
})
|
|
return formData
|
|
}
|
|
|
|
|
|
const ExistingSkippedRows: FC<ExistingSkippedRowsProps> = ({ id, rows }) => {
|
|
return (
|
|
<div id={id} className="mt-3 max-h-72 overflow-y-auto rounded border">
|
|
<div className="divide-y">
|
|
{rows.map (row => {
|
|
const post = row.existingPost
|
|
if (post == null)
|
|
{
|
|
return (
|
|
<div key={row.sourceRow} className="flex items-center gap-3 p-3">
|
|
<PostThumbnailPreview
|
|
url=""
|
|
className="h-10 w-10 shrink-0"/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
|
{row.url}
|
|
</div>
|
|
</div>
|
|
</div>)
|
|
}
|
|
return (
|
|
<PrefetchLink
|
|
key={post.id}
|
|
to={`/posts/${ post.id }`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-3 p-3">
|
|
<PostThumbnailPreview
|
|
url={post.thumbnail || post.thumbnailBase || ''}
|
|
className="h-10 w-10 shrink-0"/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium">
|
|
{post.title}
|
|
</div>
|
|
<div className="truncate text-xs text-neutral-600 dark:text-neutral-300">
|
|
{post.url}
|
|
</div>
|
|
</div>
|
|
</PrefetchLink>)
|
|
})}
|
|
</div>
|
|
</div>)
|
|
}
|
|
|
|
|
|
const editableRowDirty = (row: PostImportRow): boolean =>
|
|
row.skipReason === 'manual'
|
|
|| row.url !== row.resetSnapshot.url
|
|
|| String (row.attributes.title ?? '')
|
|
!== String (row.resetSnapshot.attributes.title ?? '')
|
|
|| String (row.attributes.thumbnailBase ?? '')
|
|
!== String (row.resetSnapshot.attributes.thumbnailBase ?? '')
|
|
|| String (row.attributes.originalCreatedFrom ?? '')
|
|
!== String (row.resetSnapshot.attributes.originalCreatedFrom ?? '')
|
|
|| String (row.attributes.originalCreatedBefore ?? '')
|
|
!== String (row.resetSnapshot.attributes.originalCreatedBefore ?? '')
|
|
|| String (row.attributes.duration ?? '')
|
|
!== String (row.resetSnapshot.attributes.duration ?? '')
|
|
|| String (row.attributes.tags ?? '')
|
|
!== String (row.resetSnapshot.attributes.tags ?? '')
|
|
|| String (row.attributes.parentPostIds ?? '')
|
|
!== String (row.resetSnapshot.attributes.parentPostIds ?? '')
|
|
|| row.thumbnailFile != null
|
|
|
|
|
|
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|
const editable = canEditContent (user)
|
|
const dialogue = useDialogue ()
|
|
const location = useLocation ()
|
|
const navigate = useNavigate ()
|
|
const behaviourSettings = useClientBehaviourSettings ()
|
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
|
const existingRowsTransition =
|
|
animationMode === 'off'
|
|
? { duration: 0 }
|
|
: animationMode === 'reduced'
|
|
? { duration: .08, ease: 'linear' as const }
|
|
: { duration: .2, ease: 'easeOut' as const }
|
|
|
|
const [rows, setRows] = useState<PostImportRow[] | null> (null)
|
|
const [metadataLoading, setMetadataLoading] = useState (false)
|
|
const [submitting, setSubmitting] = useState (false)
|
|
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
|
|
const [editingRow, setEditingRow] = useState<PostImportRow | null> (null)
|
|
const [showExistingRows, setShowExistingRows] = useState (false)
|
|
const rowsRef = useRef<PostImportRow[]> ([])
|
|
const metadataSequenceRef = useRef (0)
|
|
const discardChanges = useCallback (() => undefined, [])
|
|
|
|
const dirty = useMemo (
|
|
() => rows?.some (row => editableRowDirty (row)) ?? false,
|
|
[rows],
|
|
)
|
|
const { allowNextNavigation } = useUnsavedChangesGuard ({
|
|
dirty,
|
|
onDiscard: discardChanges,
|
|
})
|
|
|
|
const commitRows = useCallback ((nextRows: PostImportRow[]) => {
|
|
rowsRef.current = nextRows
|
|
setRows (nextRows)
|
|
return nextRows
|
|
}, [])
|
|
|
|
const updateRows = useCallback ((
|
|
updater: (currentRows: PostImportRow[]) => PostImportRow[],
|
|
) => {
|
|
const nextRows = applyDuplicateUrlErrors (updater (rowsRef.current))
|
|
return commitRows (nextRows)
|
|
}, [commitRows])
|
|
|
|
const currentRowBySource = useCallback ((
|
|
sourceRow: number,
|
|
): PostImportRow | null =>
|
|
rowsRef.current.find (row => row.sourceRow === sourceRow) ?? null,
|
|
[])
|
|
|
|
const finishImport = useCallback (() => {
|
|
allowNextNavigation ()
|
|
clearPostImportSourceDraft (message =>
|
|
toast ({ title: '入力内容を削除できませんでした', description: message }))
|
|
navigate ('/posts')
|
|
}, [allowNextNavigation, navigate])
|
|
|
|
useEffect (() => {
|
|
const urls = parsePostNewReviewUrls (location.search)
|
|
const initialRows = buildInitialRows (urls)
|
|
commitRows (initialRows)
|
|
|
|
let active = true
|
|
const sequence = ++metadataSequenceRef.current
|
|
const controllers = new Set<AbortController> ()
|
|
let nextIndex = 0
|
|
const targetSourceRows = initialRows
|
|
.filter (row => shouldFetchReviewRow (row))
|
|
.map (row => row.sourceRow)
|
|
|
|
setMetadataLoading (targetSourceRows.length > 0)
|
|
|
|
const mergeCurrentRow = (
|
|
sourceRow: number,
|
|
updater: (row: PostImportRow) => PostImportRow,
|
|
) => {
|
|
updateRows (currentRows =>
|
|
currentRows.map (row =>
|
|
row.sourceRow === sourceRow
|
|
? updater (row)
|
|
: row))
|
|
}
|
|
|
|
const worker = async () => {
|
|
while (active)
|
|
{
|
|
const sourceRow = targetSourceRows[nextIndex]
|
|
++nextIndex
|
|
if (sourceRow == null)
|
|
return
|
|
|
|
const currentRow = currentRowBySource (sourceRow)
|
|
if (currentRow == null || !(shouldFetchReviewRow (currentRow)))
|
|
continue
|
|
|
|
const controller = new AbortController ()
|
|
controllers.add (controller)
|
|
|
|
try
|
|
{
|
|
const preview = await apiGet<PostMetadataResponse> ('/posts/metadata', {
|
|
params: { url: currentRow.url },
|
|
signal: controller.signal })
|
|
if (!(active) || metadataSequenceRef.current !== sequence)
|
|
return
|
|
|
|
mergeCurrentRow (sourceRow, row =>
|
|
row.importStatus === 'created'
|
|
|| row.importStatus === 'skipped'
|
|
|| isNonRecoverableFailedRow (row)
|
|
|| row.skipReason === 'manual'
|
|
? row
|
|
: (shouldFetchMetadata (row)
|
|
? mergePreviewRow (row, preview)
|
|
: mergeExistingSkippedRow (row, preview)))
|
|
}
|
|
catch (requestError)
|
|
{
|
|
if (controller.signal.aborted)
|
|
return
|
|
if (!(active) || metadataSequenceRef.current !== sequence)
|
|
return
|
|
|
|
if (!(isApiError<{
|
|
errors?: Record<string, string[]>
|
|
baseErrors?: string[]
|
|
}> (requestError)))
|
|
{
|
|
mergeCurrentRow (sourceRow, row => ({
|
|
...row,
|
|
status: 'error' }))
|
|
continue
|
|
}
|
|
|
|
if (requestError.response?.status === 422)
|
|
{
|
|
const rowErrors = compactMessageRecord ({
|
|
...(requestError.response.data.errors ?? { }),
|
|
...(requestError.response.data.baseErrors?.length
|
|
? {
|
|
base: requestError.response.data.baseErrors }
|
|
: { }) })
|
|
mergeCurrentRow (sourceRow, row => ({
|
|
...row,
|
|
validationErrors: rowErrors,
|
|
status: 'error' }))
|
|
}
|
|
else
|
|
{
|
|
mergeCurrentRow (sourceRow, row => ({
|
|
...row,
|
|
status: 'error' }))
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
controllers.delete (controller)
|
|
}
|
|
}
|
|
}
|
|
|
|
void Promise.all (
|
|
Array.from (
|
|
{ length: Math.min (4, targetSourceRows.length) },
|
|
() => worker (),
|
|
)).finally (() => {
|
|
if (active && metadataSequenceRef.current === sequence)
|
|
setMetadataLoading (false)
|
|
})
|
|
|
|
return () => {
|
|
active = false
|
|
controllers.forEach (controller => controller.abort ())
|
|
}
|
|
}, [commitRows, currentRowBySource, location.search, updateRows])
|
|
|
|
useEffect (() => {
|
|
if (editingRow == null || resultRepairMode (rowsRef.current) !== 'failed')
|
|
return
|
|
|
|
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
|
|
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
|
}, [editingRow, rows])
|
|
|
|
const reviewRowsState = rows ?? []
|
|
const counts = useMemo (
|
|
() => reviewSummaryCounts (reviewRowsState),
|
|
[reviewRowsState],
|
|
)
|
|
const sortedRows =
|
|
resultRepairMode (reviewRowsState) === 'failed'
|
|
? [...reviewRowsState].sort ((a, b) => {
|
|
const aRepair =
|
|
a.recoverable === true
|
|
&& (a.importStatus === 'failed'
|
|
|| (a.importStatus === 'pending'
|
|
&& hasErrorMessages (a.validationErrors)))
|
|
? 0
|
|
: 1
|
|
const bRepair =
|
|
b.recoverable === true
|
|
&& (b.importStatus === 'failed'
|
|
|| (b.importStatus === 'pending'
|
|
&& hasErrorMessages (b.validationErrors)))
|
|
? 0
|
|
: 1
|
|
return aRepair - bRepair || a.sourceRow - b.sourceRow
|
|
})
|
|
: reviewRowsState
|
|
const existingRows = sortedRows.filter (row => isExistingSkipRow (row))
|
|
const reviewRows = sortedRows.filter (row => !(isExistingSkipRow (row)))
|
|
const processingRows = processableImportRows (reviewRowsState)
|
|
const canSubmit = processingRows.length > 0
|
|
|
|
const toggleManualSkip = async (sourceRow: number, checked: boolean) => {
|
|
const currentRow = currentRowBySource (sourceRow)
|
|
if (currentRow == null)
|
|
return
|
|
if (rowSkipBusy (currentRow, submitting, busyRowIds))
|
|
return
|
|
|
|
updateRows (currentRows =>
|
|
currentRows.map (row => {
|
|
if (row.sourceRow !== sourceRow)
|
|
return row
|
|
if (isExistingSkipRow (row) || row.importStatus === 'created')
|
|
return row
|
|
return {
|
|
...row,
|
|
skipReason:
|
|
checked
|
|
? 'manual'
|
|
: row.existingPostId != null
|
|
? 'existing'
|
|
: undefined }
|
|
}))
|
|
}
|
|
|
|
const saveDraft = async (
|
|
row: PostImportRow,
|
|
{ draft, resetRequested }: {
|
|
draft: PostImportEditableDraft
|
|
resetRequested: boolean },
|
|
): Promise<{ saved: boolean
|
|
row: PostImportRow | null }> => {
|
|
const currentRow = currentRowBySource (row.sourceRow)
|
|
if (currentRow == null)
|
|
return { saved: false, row: null }
|
|
if (!(canEditReviewRow (currentRow)))
|
|
return { saved: false, row: null }
|
|
|
|
const baseRow =
|
|
resetRequested
|
|
? {
|
|
...currentRow,
|
|
url: currentRow.resetSnapshot.url,
|
|
attributes: { ...currentRow.resetSnapshot.attributes },
|
|
displayTags: cloneDisplayTags (currentRow.resetSnapshot.displayTags),
|
|
provenance: { ...currentRow.resetSnapshot.provenance },
|
|
tagSources: { ...currentRow.resetSnapshot.tagSources },
|
|
fieldWarnings: cloneMessageRecord (currentRow.resetSnapshot.fieldWarnings),
|
|
baseWarnings: [...currentRow.resetSnapshot.baseWarnings],
|
|
metadataUrl: currentRow.resetSnapshot.metadataUrl,
|
|
thumbnailFile: undefined }
|
|
: currentRow
|
|
const urlChanged = draft.url !== baseRow.url
|
|
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
|
let candidateRow = nextRow
|
|
|
|
try
|
|
{
|
|
candidateRow =
|
|
urlChanged
|
|
? mergePreviewRow (
|
|
nextRow,
|
|
await apiGet<PostMetadataResponse> ('/posts/metadata', {
|
|
params: { url: nextRow.url } }))
|
|
: nextRow
|
|
const dryRun = await apiPost<PostMetadataResponse> (
|
|
'/posts?dry=1',
|
|
buildDryRunFormData (candidateRow))
|
|
const mergedRow = mergeDryRunRow (candidateRow, dryRun)
|
|
const mergedRows = updateRows (currentRows =>
|
|
replaceImportRow (currentRows, mergedRow))
|
|
const mergedTarget = mergedRows.find (
|
|
current => current.sourceRow === baseRow.sourceRow)
|
|
if (mergedTarget == null)
|
|
return { saved: false, row: null }
|
|
if (hasErrorMessages (mergedTarget.validationErrors))
|
|
return { saved: false, row: mergedTarget }
|
|
return { saved: true, row: null }
|
|
}
|
|
catch (requestError)
|
|
{
|
|
if (isApiError<{
|
|
errors?: Record<string, string[]>
|
|
baseErrors?: string[]
|
|
}> (requestError)
|
|
&& requestError.response?.status === 422)
|
|
{
|
|
const errorRow = {
|
|
...candidateRow,
|
|
validationErrors: compactMessageRecord ({
|
|
...(requestError.response.data.errors ?? { }),
|
|
...(requestError.response.data.baseErrors?.length
|
|
? {
|
|
base: requestError.response.data.baseErrors }
|
|
: { }) }),
|
|
baseWarnings: [],
|
|
fieldWarnings: { },
|
|
status: 'error' as const }
|
|
return { saved: false, row: applyThumbnailWarning (errorRow) }
|
|
}
|
|
|
|
toast ({ title: '行の再検証に失敗しました' })
|
|
return { saved: false, row: null }
|
|
}
|
|
}
|
|
|
|
const openEditingDialogue = async (row: PostImportRow) => {
|
|
const saveRowDraft = (
|
|
{ draft, resetRequested }: {
|
|
draft: PostImportEditableDraft
|
|
resetRequested: boolean },
|
|
) =>
|
|
saveDraft (row, { draft, resetRequested })
|
|
|
|
await dialogue.form ({
|
|
title: '投稿を編輯',
|
|
cancelText: '取消',
|
|
size: 'large',
|
|
body: controls => (
|
|
<PostImportRowForm
|
|
row={row}
|
|
controls={controls}
|
|
onSave={saveRowDraft}/>) })
|
|
}
|
|
|
|
const editRow = async (row: PostImportRow) => {
|
|
const currentRow = currentRowBySource (row.sourceRow)
|
|
if (currentRow == null)
|
|
return
|
|
if (!(canEditReviewRow (currentRow)))
|
|
return
|
|
if (rowOperationBusy (currentRow, submitting, busyRowIds))
|
|
return
|
|
|
|
setEditingRow (currentRow)
|
|
try
|
|
{
|
|
await openEditingDialogue (currentRow)
|
|
}
|
|
finally
|
|
{
|
|
setEditingRow (current =>
|
|
current?.sourceRow === currentRow.sourceRow
|
|
? null
|
|
: current)
|
|
}
|
|
}
|
|
|
|
const retry = async (sourceRow: number) => {
|
|
const originalRow = currentRowBySource (sourceRow)
|
|
if (originalRow == null)
|
|
return
|
|
if (rowOperationBusy (originalRow, submitting, busyRowIds))
|
|
return
|
|
|
|
setBusyRowIds (current => new Set ([...current, sourceRow]))
|
|
try
|
|
{
|
|
updateRows (currentRows => retryImportRow (currentRows, sourceRow))
|
|
const target = currentRowBySource (sourceRow)
|
|
if (target == null)
|
|
return
|
|
|
|
const result = await apiPost<{ results: BulkApiRow[] }>(
|
|
'/posts/bulk',
|
|
buildBulkFormData ([target]))
|
|
if (result.results.length !== 1)
|
|
{
|
|
updateRows (currentRows => replaceImportRow (currentRows, originalRow))
|
|
toast ({ title: '登録結果が不完全でした' })
|
|
return
|
|
}
|
|
|
|
const indexedResults = indexedBulkResults ([target], result.results)
|
|
const nextRows = applyDuplicateUrlErrors (
|
|
mergeImportResults (rowsRef.current, indexedResults))
|
|
.map (row => applyThumbnailWarning (row))
|
|
commitRows (nextRows)
|
|
if (nextRows.every (row => isCompletedReviewRow (row)))
|
|
finishImport ()
|
|
}
|
|
catch
|
|
{
|
|
updateRows (currentRows => replaceImportRow (currentRows, originalRow))
|
|
toast ({ title: '再試行に失敗しました' })
|
|
}
|
|
finally
|
|
{
|
|
setBusyRowIds (current => {
|
|
const next = new Set (current)
|
|
next.delete (sourceRow)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
const submit = async () => {
|
|
if (metadataLoading || submitting || busyRowIds.size > 0)
|
|
return
|
|
|
|
const latestProcessingRows = processableImportRows (rowsRef.current)
|
|
if (latestProcessingRows.length === 0)
|
|
return
|
|
|
|
setSubmitting (true)
|
|
try
|
|
{
|
|
const result = await apiPost<{ results: BulkApiRow[] }>(
|
|
'/posts/bulk',
|
|
buildBulkFormData (latestProcessingRows))
|
|
if (result.results.length !== latestProcessingRows.length)
|
|
{
|
|
toast ({ title: '登録結果が不完全でした' })
|
|
return
|
|
}
|
|
|
|
const indexedResults = indexedBulkResults (latestProcessingRows, result.results)
|
|
const nextRows = applyDuplicateUrlErrors (
|
|
mergeImportResults (rowsRef.current, indexedResults))
|
|
.map (row => applyThumbnailWarning (row))
|
|
commitRows (nextRows)
|
|
if (nextRows.every (row => isCompletedReviewRow (row)))
|
|
finishImport ()
|
|
}
|
|
catch
|
|
{
|
|
toast ({ title: '登録に失敗しました' })
|
|
}
|
|
finally
|
|
{
|
|
setSubmitting (false)
|
|
}
|
|
}
|
|
|
|
if (!(editable))
|
|
return <Forbidden/>
|
|
|
|
if (rows == null)
|
|
return null
|
|
|
|
return (
|
|
<>
|
|
<Helmet>
|
|
<title>{`追加内容確認 | ${ SITE_TITLE }`}</title>
|
|
</Helmet>
|
|
|
|
<MainArea className="pb-40">
|
|
<div className="mx-auto max-w-6xl space-y-6">
|
|
<PageTitle>追加内容確認</PageTitle>
|
|
|
|
{existingRows.length > 0 && (
|
|
<div className="rounded-lg border p-4">
|
|
<button
|
|
type="button"
|
|
aria-expanded={showExistingRows}
|
|
aria-controls="post-import-existing-skips"
|
|
className="flex items-center gap-2 text-left text-sm font-medium"
|
|
onClick={() => setShowExistingRows (current => !(current))}>
|
|
<span className="inline-flex">
|
|
<ChevronRight
|
|
className={cn (
|
|
'h-4 w-4',
|
|
showExistingRows && 'rotate-90',
|
|
animationMode === 'off'
|
|
? ''
|
|
: 'transition-transform')}/>
|
|
</span>
|
|
既存投稿による自動スキップ {counts.existingSkipped}件
|
|
</button>
|
|
<AnimatePresence initial={false}>
|
|
{showExistingRows && (
|
|
<motion.div
|
|
initial={
|
|
animationMode === 'off'
|
|
? false
|
|
: { height: 0, opacity: 0 }
|
|
}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={existingRowsTransition}
|
|
className="overflow-hidden">
|
|
<ExistingSkippedRows
|
|
id="post-import-existing-skips"
|
|
rows={existingRows}/>
|
|
</motion.div>)}
|
|
</AnimatePresence>
|
|
</div>)}
|
|
|
|
<div className="space-y-3">
|
|
{reviewRows.map ((row, index) => (
|
|
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
|
|
<PostImportRowSummary
|
|
row={row}
|
|
displayNumber={index + 1}
|
|
showSkipToggle={true}
|
|
editDisabled={rowOperationBusy (row, submitting, busyRowIds)}
|
|
retryDisabled={rowOperationBusy (row, submitting, busyRowIds)}
|
|
skipDisabled={
|
|
rowSkipBusy (row, submitting, busyRowIds)
|
|
|| isExistingSkipRow (row)
|
|
|| row.importStatus === 'created'}
|
|
onEdit={() => editRow (row)}
|
|
onToggleSkip={checked => toggleManualSkip (row.sourceRow, checked)}
|
|
onRetry={canRetryResultRow (row) ? () => retry (row.sourceRow) : undefined}
|
|
rowMessages={resultRowMessages (row)}/>
|
|
</div>))}
|
|
</div>
|
|
</div>
|
|
</MainArea>
|
|
|
|
<PostImportFooter
|
|
metadataLoading={metadataLoading}
|
|
submitting={submitting}
|
|
busyRowsPresent={busyRowIds.size > 0}
|
|
canSubmit={canSubmit}
|
|
creatableCount={counts.creatable}
|
|
manualSkippedCount={counts.manualSkipped}
|
|
existingSkippedCount={counts.existingSkipped}
|
|
pendingOrErrorCount={counts.pendingOrError}
|
|
onBack={() => navigate ('/posts/new')}
|
|
onSubmit={() => submit ()}/>
|
|
</>)
|
|
}
|
|
|
|
|
|
const PostImportFooter = (
|
|
{ metadataLoading,
|
|
submitting,
|
|
busyRowsPresent,
|
|
canSubmit,
|
|
creatableCount,
|
|
manualSkippedCount,
|
|
existingSkippedCount,
|
|
pendingOrErrorCount,
|
|
onBack,
|
|
onSubmit }: { metadataLoading: boolean
|
|
submitting: boolean
|
|
busyRowsPresent: boolean
|
|
canSubmit: boolean
|
|
creatableCount: number
|
|
manualSkippedCount: number
|
|
existingSkippedCount: number
|
|
pendingOrErrorCount: number
|
|
onBack: () => void
|
|
onSubmit: () => void },
|
|
) => (
|
|
<div
|
|
className="shrink-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">
|
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
<span>作成対象 {creatableCount}件</span>
|
|
<span>手動スキップ {manualSkippedCount}件</span>
|
|
<span>既存投稿による自動スキップ {existingSkippedCount}件</span>
|
|
<span>未処理 {pendingOrErrorCount}件</span>
|
|
</div>
|
|
<div className="flex flex-col gap-2 md:flex-row">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full md:w-auto"
|
|
onClick={onBack}
|
|
disabled={submitting}>
|
|
戻る
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="w-full md:w-auto"
|
|
onClick={onSubmit}
|
|
disabled={metadataLoading || submitting || busyRowsPresent || !(canSubmit)}>
|
|
{creatableCount > 1 ? '一括追加' : '追加'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>)
|
|
|
|
export default PostImportReviewPage
|