ファイル
btrc-hub/frontend/src/components/posts/import/PostImportRowForm.tsx
T
2026-07-18 19:26:27 +09:00

309 行
10 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import PostCreationDataFields from '@/components/posts/PostCreationDataFields'
import PostDurationField from '@/components/posts/PostDurationField'
import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import type { FC } from 'react'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
type Draft = PostImportEditableDraft
type Props = {
row: PostImportRow
controls: DialogueFormControls
onSave: (args: { draft: Draft
resetRequested: boolean }) => Promise<{
saved: boolean
row: PostImportRow | null }> }
const THUMBNAIL_MISSING_WARNING = 'サムネールなし'
const buildDraft = (row: PostImportRow): Draft => ({
url: row.url,
title: String (row.attributes.title ?? ''),
thumbnailBase: String (row.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.attributes.originalCreatedBefore ?? ''),
tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? ''),
duration: String (row.attributes.duration ?? ''),
thumbnailFile: row.thumbnailFile })
const buildResetDraft = (row: PostImportRow): Draft => ({
url: row.resetSnapshot.url,
title: String (row.resetSnapshot.attributes.title ?? ''),
thumbnailBase: String (row.resetSnapshot.attributes.thumbnailBase ?? ''),
originalCreatedFrom: String (row.resetSnapshot.attributes.originalCreatedFrom ?? ''),
originalCreatedBefore: String (row.resetSnapshot.attributes.originalCreatedBefore ?? ''),
tags: String (row.resetSnapshot.attributes.tags ?? ''),
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? ''),
duration: String (row.resetSnapshot.attributes.duration ?? ''),
thumbnailFile: undefined })
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const sameDraft = (left: Draft, right: Draft): boolean =>
left.url === right.url
&& left.title === right.title
&& left.thumbnailBase === right.thumbnailBase
&& left.originalCreatedFrom === right.originalCreatedFrom
&& left.originalCreatedBefore === right.originalCreatedBefore
&& left.tags === right.tags
&& left.parentPostIds === right.parentPostIds
&& left.duration === right.duration
&& left.thumbnailFile === right.thumbnailFile
const sameProvenance = (
current: PostImportRow['provenance'],
reset: PostImportRow['resetSnapshot']['provenance'],
): boolean =>
Object.keys (reset).every (field => current[field] === reset[field])
const sameTagSources = (
current: PostImportRow['tagSources'],
reset: PostImportRow['resetSnapshot']['tagSources'],
): boolean =>
(current?.automatic ?? '') === reset.automatic
&& (current?.manual ?? '') === reset.manual
const sameWarnings = (
current: PostImportRow,
reset: PostImportRow['resetSnapshot'],
): boolean =>
JSON.stringify (current.fieldWarnings) === JSON.stringify (reset.fieldWarnings)
&& JSON.stringify (current.baseWarnings) === JSON.stringify (reset.baseWarnings)
const thumbnailWarnings = (
messages: string[] | undefined,
thumbnailBase: string,
thumbnailFile: File | undefined,
): string[] => {
const others = (messages ?? []).filter (message => message !== THUMBNAIL_MISSING_WARNING)
return hasThumbnailBaseValue (thumbnailBase) || thumbnailFile != null
? others
: [...new Set ([...others, THUMBNAIL_MISSING_WARNING])]
}
const PostImportRowForm: FC<Props> = (
{ row,
controls,
onSave },
) => {
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
const [saving, setSaving] = useState (false)
const [resetRequested, setResetRequested] = useState (false)
const [committedThumbnailBase, setCommittedThumbnailBase] = useState (
() => String (row.attributes.thumbnailBase ?? ''))
useEffect (() => {
const nextDraft = buildDraft (row)
setDraft (nextDraft)
setMessageRow (null)
setResetRequested (false)
setCommittedThumbnailBase (String (row.attributes.thumbnailBase ?? ''))
}, [row])
const displayRow = messageRow ?? row
const resetDraft = useMemo (
() => buildResetDraft (row),
[row])
const durationVisible = hasVideoTag (draft.tags)
const currentThumbnailWarnings = thumbnailWarnings (
displayRow.fieldWarnings.thumbnailBase,
draft.thumbnailBase,
draft.thumbnailFile)
const resetDisabled =
saving
|| (sameDraft (draft, resetDraft)
&& sameProvenance (row.provenance, row.resetSnapshot.provenance)
&& sameTagSources (row.tagSources, row.resetSnapshot.tagSources)
&& row.metadataUrl === row.resetSnapshot.metadataUrl
&& sameWarnings (displayRow, row.resetSnapshot))
const update = <Key extends keyof Draft,> (
key: Key,
value: Draft[Key],
) => {
if (messageRow != null)
setMessageRow (null)
setDraft (current => ({ ...current, [key]: value }))
}
const reset = useCallback (async (): Promise<boolean> => {
if (resetDisabled)
return false
const confirmed = await controls.confirm ({
title: '変更をリセットしますか?',
confirmText: 'リセット',
cancelText: '取消',
variant: 'danger' })
if (!(confirmed))
return false
setDraft (resetDraft)
setResetRequested (true)
setMessageRow (null)
setCommittedThumbnailBase (resetDraft.thumbnailBase)
return false
}, [controls, resetDisabled, resetDraft])
const save = useCallback (async (): Promise<boolean> => {
setSaving (true)
try
{
const result = await onSave ({ draft, resetRequested })
if (result.saved)
return true
if (result.row != null)
setMessageRow (result.row)
return false
}
finally
{
setSaving (false)
}
}, [draft, onSave, resetRequested])
useEffect (() => {
controls.setActions ([{
label: '変更をリセット',
placement: 'start',
variant: 'danger',
disabled: resetDisabled,
onSelect: reset },
{
label: '編輯内容を保存',
disabled: saving,
onSelect: save }])
}, [controls, resetDisabled, reset, save, saving])
return (
<>
<div className="px-6 pb-6">
<div className="space-y-4">
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<div className="space-y-3 md:sticky md:top-0 md:self-start">
<PostImportThumbnailPreview
url={committedThumbnailBase}
file={
hasThumbnailBaseValue (committedThumbnailBase)
? undefined
: draft.thumbnailFile}
className="h-28 w-28"/>
</div>
<div className="space-y-4">
<PostCreationDataFields
url={{
value: draft.url,
onChange: value => update ('url', value),
disabled: saving,
warnings: displayRow.fieldWarnings.url,
errors: groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url) }}
thumbnailField={
<>
<PostTextField
label="サムネール"
value={draft.thumbnailBase}
disabled={saving}
warnings={currentThumbnailWarnings}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
onBlur={() => {
if (draft.thumbnailBase.trim () !== committedThumbnailBase.trim ())
setCommittedThumbnailBase (draft.thumbnailBase)
}}
onChange={value => update ('thumbnailBase', value)}/>
{!(hasThumbnailBaseValue (draft.thumbnailBase)) && (
<input
type="file"
accept="image/*"
disabled={saving}
onChange={event => {
const file = event.target.files?.[0]
update ('thumbnailFile', file)
}}/>)}
</>}
core={{
title: {
value: draft.title,
onChange: value => update ('title', value),
disabled: saving,
warnings: displayRow.fieldWarnings.title,
errors: groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title) },
originalCreated: {
disabled: saving,
originalCreatedFrom: draft.originalCreatedFrom || null,
setOriginalCreatedFrom: value =>
update ('originalCreatedFrom', value ?? ''),
originalCreatedBefore: draft.originalCreatedBefore || null,
setOriginalCreatedBefore: value =>
update ('originalCreatedBefore', value ?? ''),
errors: {
originalCreatedAt: groupedMessages (
displayRow.validationErrors.originalCreatedAt,
displayRow.importErrors?.originalCreatedAt),
originalCreatedFrom: groupedMessages (
displayRow.validationErrors.originalCreatedFrom,
displayRow.importErrors?.originalCreatedFrom),
originalCreatedBefore: groupedMessages (
displayRow.validationErrors.originalCreatedBefore,
displayRow.importErrors?.originalCreatedBefore) } },
tags: {
value: draft.tags,
onChange: value => update ('tags', value),
disabled: saving,
warnings: displayRow.fieldWarnings.tags,
errors: groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags),
rows: 4 },
parentPostIds: {
value: draft.parentPostIds,
onChange: value => update ('parentPostIds', value),
disabled: saving,
errors: groupedMessages (
displayRow.validationErrors.parentPostIds,
displayRow.importErrors?.parentPostIds) } }}
extraFields={
durationVisible
? (
<PostDurationField
value={draft.duration}
onChange={value => update ('duration', value)}
disabled={saving}
errors={groupedMessages (
displayRow.validationErrors.videoMs,
displayRow.importErrors?.videoMs)}/>)
: null}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
</div>
</div>
</div>
</div>
</>)
}
export default PostImportRowForm
export { buildDraft }
export type { Draft as PostImportRowDraft }