このコミットが含まれているのは:
2026-07-13 01:17:11 +09:00
コミット be68841bd3
9個のファイルの変更265行の追加217行の削除
+1 -1
ファイルの表示
@@ -182,7 +182,7 @@ class PostImportPreviewer
attributes['tags'] = merged_tags(tag_sources) attributes['tags'] = merged_tags(tag_sources)
next next
end end
next unless provenance[field] == 'automatic' || attributes[field].blank? next unless provenance[field] == 'automatic'
attributes[field] = value attributes[field] = value
provenance[field] = 'automatic' provenance[field] = 'automatic'
+18 -8
ファイルの表示
@@ -2,10 +2,11 @@ import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { FC } from 'react' import type { FC, ReactNode } from 'react'
type Props = { type Props = {
disabled?: boolean disabled?: boolean
labelAddon?: ReactNode
originalCreatedFrom: string | null originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null originalCreatedBefore: string | null
@@ -15,18 +16,25 @@ type Props = {
const PostOriginalCreatedTimeField: FC<Props> = ( const PostOriginalCreatedTimeField: FC<Props> = (
{ disabled, { disabled,
labelAddon,
originalCreatedFrom, originalCreatedFrom,
setOriginalCreatedFrom, setOriginalCreatedFrom,
originalCreatedBefore, originalCreatedBefore,
setOriginalCreatedBefore, setOriginalCreatedBefore,
errors }: Props) => ( errors }: Props) => (
<FormField label="オリジナルの作成日時" messages={errors}> <FormField
label={
<span className="flex items-center gap-2">
<span></span>
{labelAddon}
</span>}
messages={errors}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<div className="my-1 flex"> <div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="w-80"> <div className="min-w-0 flex-1">
<DateTimeField <DateTimeField
className="mr-2" className="w-full"
disabled={disabled ?? false} disabled={disabled ?? false}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
@@ -49,6 +57,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
</div> </div>
<div> <div>
<Button <Button
type="button"
className="bg-gray-600 text-white rounded" className="bg-gray-600 text-white rounded"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
@@ -59,10 +68,10 @@ const PostOriginalCreatedTimeField: FC<Props> = (
</div> </div>
</div> </div>
<div className="my-1 flex"> <div className="my-1 flex flex-col gap-2 sm:flex-row sm:items-start">
<div className="w-80"> <div className="min-w-0 flex-1">
<DateTimeField <DateTimeField
className="mr-2" className="w-full"
disabled={disabled} disabled={disabled}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
@@ -73,6 +82,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
</div> </div>
<div> <div>
<Button <Button
type="button"
className="bg-gray-600 text-white rounded" className="bg-gray-600 text-white rounded"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
+18
ファイルの表示
@@ -0,0 +1,18 @@
import type { FC } from 'react'
type Props = { id?: string
messages?: string[] }
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
if (!(messages) || messages.length === 0)
return null
return (
<ul id={id} className="mt-1 space-y-1 text-amber-700 dark:text-amber-200">
{messages.map ((message, i) => <li key={i}>{message}</li>)}
</ul>)
}
export default FieldWarning
+130 -132
ファイルの表示
@@ -1,4 +1,7 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import FieldWarning from '@/components/common/FieldWarning'
import FormField from '@/components/common/FormField'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview' import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -49,9 +52,10 @@ const buildDraft = (row: PostImportRow): Draft => ({
tags: String (row.attributes.tags ?? ''), tags: String (row.attributes.tags ?? ''),
parentPostIds: String (row.attributes.parentPostIds ?? '') }) parentPostIds: String (row.attributes.parentPostIds ?? '') })
const warningClass = const groupedMessages = (
'mt-1 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm ' ...values: Array<string[] | undefined>
+ 'text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200' ): string[] =>
values.flatMap (value => value ?? [])
const PostImportRowDialog: FC<Props> = ( const PostImportRowDialog: FC<Props> = (
@@ -82,109 +86,101 @@ const PostImportRowDialog: FC<Props> = (
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl px-6 pb-6 pt-7"> <DialogContent className="flex max-h-[calc(100dvh-1rem)] max-w-3xl flex-col
<DialogHeader className="pl-8"> overflow-hidden p-0">
<DialogTitle>稿 {row.sourceRow} </DialogTitle> <DialogHeader className="shrink-0 px-6 pb-4 pt-7">
<DialogTitle>稿</DialogTitle>
<DialogDescription> <DialogDescription>
稿 {row.sourceRow}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-6">
<div className="space-y-3"> <div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<ThumbnailPreview <div className="space-y-3">
url={draft.thumbnailBase} <ThumbnailPreview
className="h-28 w-28"/> url={draft.thumbnailBase}
<div className="flex flex-wrap gap-2"> className="h-28 w-28"/>
<PostImportStatusBadge value={row.status}/> <div className="flex flex-wrap gap-2">
<PostImportStatusBadge value={row.importStatus ?? 'pending'}/> <PostImportStatusBadge value={row.status}/>
<PostImportStatusBadge value={row.importStatus ?? 'pending'}/>
</div>
</div> </div>
</div>
<div className="space-y-4"> <div className="space-y-4">
<DialogField <DialogTextField
label="URL" label="URL"
value={draft.url} value={draft.url}
origin={originOf (row, 'url')} origin={originOf (row, 'url')}
warnings={row.fieldWarnings.url} warnings={row.fieldWarnings.url}
errors={[ errors={groupedMessages (row.validationErrors.url, row.importErrors?.url)}
...(row.validationErrors.url ?? []), onChange={value => update ('url', value)}/>
...(row.importErrors?.url ?? [])]} <DialogTextField
onChange={value => update ('url', value)}/> label="タイトル"
<DialogField value={draft.title}
label="タイトル" origin={originOf (row, 'title')}
value={draft.title} warnings={row.fieldWarnings.title}
origin={originOf (row, 'title')} errors={groupedMessages (row.validationErrors.title, row.importErrors?.title)}
warnings={row.fieldWarnings.title} onChange={value => update ('title', value)}/>
errors={[ <DialogTextField
...(row.validationErrors.title ?? []), label="サムネール基底 URL"
...(row.importErrors?.title ?? [])]} value={draft.thumbnailBase}
onChange={value => update ('title', value)}/> origin={originOf (row, 'thumbnailBase')}
<DialogField warnings={row.fieldWarnings.thumbnailBase}
label="サムネール基底 URL" errors={groupedMessages (
value={draft.thumbnailBase} row.validationErrors.thumbnailBase,
origin={originOf (row, 'thumbnailBase')} row.importErrors?.thumbnailBase,
warnings={row.fieldWarnings.thumbnailBase} )}
errors={[ onChange={value => update ('thumbnailBase', value)}/>
...(row.validationErrors.thumbnailBase ?? []), <PostOriginalCreatedTimeField
...(row.importErrors?.thumbnailBase ?? [])]} labelAddon={<PostImportStatusBadge value={originOf (row, 'originalCreatedFrom')}/>}
onChange={value => update ('thumbnailBase', value)}/> originalCreatedFrom={draft.originalCreatedFrom || null}
<div className="grid gap-4 md:grid-cols-2"> setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
<DialogField originalCreatedBefore={draft.originalCreatedBefore || null}
label="作成日時 From" setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
value={draft.originalCreatedFrom} errors={groupedMessages (
origin={originOf (row, 'originalCreatedFrom')} row.validationErrors.originalCreatedAt,
errors={[ row.validationErrors.originalCreatedFrom,
...(row.validationErrors.originalCreatedFrom ?? []), row.validationErrors.originalCreatedBefore,
...(row.importErrors?.originalCreatedFrom ?? [])]} row.importErrors?.originalCreatedAt,
onChange={value => update ('originalCreatedFrom', value)}/> row.importErrors?.originalCreatedFrom,
<DialogField row.importErrors?.originalCreatedBefore,
label="作成日時 Before" )}/>
value={draft.originalCreatedBefore} <DialogTextField
origin={originOf (row, 'originalCreatedBefore')} label="動画時間"
errors={[ value={draft.duration}
...(row.validationErrors.originalCreatedBefore ?? []), origin={originOf (row, 'duration')}
...(row.importErrors?.originalCreatedBefore ?? [])]} errors={groupedMessages (
onChange={value => update ('originalCreatedBefore', value)}/> row.validationErrors.duration,
row.validationErrors.videoMs,
row.importErrors?.duration,
row.importErrors?.videoMs,
)}
onChange={value => update ('duration', value)}/>
<DialogAreaField
label="タグ"
value={draft.tags}
origin={originOf (row, 'tags')}
warnings={row.fieldWarnings.tags}
errors={groupedMessages (row.validationErrors.tags, row.importErrors?.tags)}
onChange={value => update ('tags', value)}/>
<DialogTextField
label="親投稿"
value={draft.parentPostIds}
origin={originOf (row, 'parentPostIds')}
errors={groupedMessages (
row.validationErrors.parentPostIds,
row.importErrors?.parentPostIds,
)}
onChange={value => update ('parentPostIds', value)}/>
<FieldWarning messages={row.baseWarnings}/>
<FieldError messages={row.validationErrors.base}/>
<FieldError messages={row.importErrors?.base}/>
</div> </div>
<DialogField
label="動画時間"
value={draft.duration}
origin={originOf (row, 'duration')}
errors={[
...(row.validationErrors.duration ?? []),
...(row.validationErrors.videoMs ?? []),
...(row.importErrors?.duration ?? []),
...(row.importErrors?.videoMs ?? [])]}
onChange={value => update ('duration', value)}/>
<DialogArea
label="タグ"
value={draft.tags}
origin={originOf (row, 'tags')}
warnings={row.fieldWarnings.tags}
errors={[
...(row.validationErrors.tags ?? []),
...(row.importErrors?.tags ?? [])]}
onChange={value => update ('tags', value)}/>
<DialogField
label="親投稿"
value={draft.parentPostIds}
origin={originOf (row, 'parentPostIds')}
errors={[
...(row.validationErrors.parentPostIds ?? []),
...(row.importErrors?.parentPostIds ?? [])]}
onChange={value => update ('parentPostIds', value)}/>
{row.baseWarnings.length > 0 && (
<div className={warningClass}>
{row.baseWarnings.map ((message, index) => (
<div key={`${ message }-${ index }`}>{message}</div>))}
</div>)}
<FieldError messages={row.validationErrors.base}/>
<FieldError messages={row.importErrors?.base}/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter className="shrink-0 px-6 pb-6 pt-4">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -195,14 +191,14 @@ const PostImportRowDialog: FC<Props> = (
type="button" type="button"
onClick={save} onClick={save}
disabled={saving}> disabled={saving}>
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog>) </Dialog>)
} }
const DialogField = ( const DialogTextField = (
{ label, value, origin, warnings, errors, onChange }: { { label, value, origin, warnings, errors, onChange }: {
label: string label: string
value: string value: string
@@ -211,24 +207,20 @@ const DialogField = (
errors?: string[] errors?: string[]
onChange: (value: string) => void }, onChange: (value: string) => void },
) => ( ) => (
<label className="block space-y-1"> <FormField label={<DialogLabel label={label} origin={origin}/>} messages={errors}>
<span className="flex items-center gap-2 text-sm font-medium"> {({ describedBy, invalid }) => (
{label} <>
<PostImportStatusBadge value={origin}/> <input
</span> value={value}
<input onChange={ev => onChange (ev.target.value)}
value={value} aria-describedby={describedBy}
onChange={ev => onChange (ev.target.value)} aria-invalid={invalid}
className={inputClass (false)}/> className={inputClass (invalid)}/>
{warnings && warnings.length > 0 && ( <FieldWarning messages={warnings}/>
<div className={warningClass}> </>)}
{warnings.map ((message, index) => ( </FormField>)
<div key={`${ message }-${ index }`}>{message}</div>))}
</div>)}
<FieldError messages={errors}/>
</label>)
const DialogArea = ( const DialogAreaField = (
{ label, value, origin, warnings, errors, onChange }: { { label, value, origin, warnings, errors, onChange }: {
label: string label: string
value: string value: string
@@ -237,22 +229,28 @@ const DialogArea = (
errors?: string[] errors?: string[]
onChange: (value: string) => void }, onChange: (value: string) => void },
) => ( ) => (
<label className="block space-y-1"> <FormField label={<DialogLabel label={label} origin={origin}/>} messages={errors}>
<span className="flex items-center gap-2 text-sm font-medium"> {({ describedBy, invalid }) => (
{label} <>
<PostImportStatusBadge value={origin}/> <textarea
</span> value={value}
<textarea rows={4}
value={value} onChange={ev => onChange (ev.target.value)}
rows={4} aria-describedby={describedBy}
onChange={ev => onChange (ev.target.value)} aria-invalid={invalid}
className={inputClass (false)}/> className={inputClass (invalid)}/>
{warnings && warnings.length > 0 && ( <FieldWarning messages={warnings}/>
<div className={warningClass}> </>)}
{warnings.map ((message, index) => ( </FormField>)
<div key={`${ message }-${ index }`}>{message}</div>))}
</div>)} const DialogLabel = (
<FieldError messages={errors}/> { label, origin }: {
</label>) label: string
origin: PostImportOrigin },
) => (
<span className="flex items-center gap-2">
<span>{label}</span>
<PostImportStatusBadge value={origin}/>
</span>)
export default PostImportRowDialog export default PostImportRowDialog
+2 -2
ファイルの表示
@@ -126,7 +126,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={row.importStatus === 'created'}> disabled={row.importStatus === 'created'}>
</Button> </Button>
</div> </div>
</div> </div>
@@ -174,7 +174,7 @@ const PostImportRowSummary: FC<Props> = ({ row, onEdit }) => {
variant="outline" variant="outline"
onClick={onEdit} onClick={onEdit}
disabled={row.importStatus === 'created'}> disabled={row.importStatus === 'created'}>
</Button> </Button>
</div> </div>
</>) </>)
+1 -1
ファイルの表示
@@ -412,7 +412,7 @@ export const mergeValidatedImportRows = (
: { ...previous.fieldWarnings } : { ...previous.fieldWarnings }
for (const [field, origin] of Object.entries (row.provenance)) for (const [field, origin] of Object.entries (row.provenance))
{ {
if (origin === 'manual' && String (row.attributes[field] ?? '') !== '') if (origin === 'manual')
delete fieldWarnings[field] delete fieldWarnings[field]
} }
+1 -1
ファイルの表示
@@ -215,7 +215,7 @@ const PostImportResultPage: FC<Props> = ({ user }) => {
type="button" type="button"
variant="outline" variant="outline"
onClick={() => openRepair (row.sourceRow)}> onClick={() => openRepair (row.sourceRow)}>
</Button> </Button>
<Button <Button
type="button" type="button"
+68 -55
ファイルの表示
@@ -4,7 +4,6 @@ import { useNavigate,
useParams, useParams,
useSearchParams } from 'react-router-dom' useSearchParams } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog' import PostImportRowDialog from '@/components/posts/import/PostImportRowDialog'
@@ -98,7 +97,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
return return
const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`) const element = document.getElementById (`post-import-row-${ editingRow.sourceRow }`)
element?.scrollIntoView ({ block: 'center', behaviour: 'smooth' }) element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
}, [editingRow, session?.repairMode]) }, [editingRow, session?.repairMode])
const updateSessionRows = (nextRows: PostImportRow[]) => const updateSessionRows = (nextRows: PostImportRow[]) =>
@@ -233,7 +232,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<MainArea> <MainArea>
<div className="mx-auto max-w-4xl space-y-4 p-4"> <div className="mx-auto max-w-4xl space-y-4 p-4">
<PageTitle>稿</PageTitle> <PageTitle>稿</PageTitle>
<FieldError messages={['取込状態が見つかりません.']}/> <div className="text-red-700 dark:text-red-300"></div>
<Button type="button" onClick={() => navigate ('/posts/import')}> <Button type="button" onClick={() => navigate ('/posts/import')}>
URL URL
</Button> </Button>
@@ -242,65 +241,46 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
} }
return ( return (
<MainArea> <>
<Helmet> <Helmet>
<title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title> <title>{`投稿インポート確認 | ${ SITE_TITLE }`}</title>
</Helmet> </Helmet>
<div className="mx-auto flex max-w-6xl flex-col gap-4 p-4 pb-8"> <MainArea className="min-h-0">
<PageTitle>稿</PageTitle> <div className="mx-auto max-w-6xl space-y-4 p-4">
<PageTitle>稿</PageTitle>
<div className="rounded-lg border bg-white p-4 dark:border-neutral-700 <div className="rounded-lg border bg-white p-4 dark:border-neutral-700
dark:bg-neutral-900"> dark:bg-neutral-900">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<SummaryChip label="全件" value={counts.total}/> <SummaryChip label="全件" value={counts.total}/>
<SummaryChip label="登録対象" value={counts.submittable}/> <SummaryChip label="登録対象" value={counts.submittable}/>
<SummaryChip label="要修正" value={counts.invalid}/> <SummaryChip label="要修正" value={counts.invalid}/>
<SummaryChip label="スキップ予定" value={counts.skipPlanned}/> <SummaryChip label="スキップ予定" value={counts.skipPlanned}/>
<SummaryChip label="登録済み" value={counts.created}/> <SummaryChip label="登録済み" value={counts.created}/>
<SummaryChip label="失敗" value={counts.failed}/> <SummaryChip label="失敗" value={counts.failed}/>
</div>
</div>
<div className="space-y-3">
{reviewRows.map (row => (
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}>
<PostImportRowSummary
row={row}
onEdit={() => {
setSearchParams ({ edit: String (row.sourceRow) })
}}/>
</div>))}
</div> </div>
</div> </div>
</MainArea>
<div className="flex-1 space-y-3 pb-24"> <PostImportFooter
{reviewRows.map (row => ( loading={loading}
<div key={row.sourceRow} id={`post-import-row-${ row.sourceRow }`}> invalidCount={counts.invalid}
<PostImportRowSummary submittableCount={submittable.length}
row={row} onBack={() => navigate ('/posts/import')}
onEdit={() => { onSubmit={submit}/>
setSearchParams ({ edit: String (row.sourceRow) })
}}/>
</div>))}
</div>
</div>
<div
className="sticky bottom-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">
<PostImportStatusBadge value="pending"/>
<span> {submittable.length} </span>
<PostImportStatusBadge value="error"/>
<span> {counts.invalid} </span>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
onClick={() => navigate ('/posts/import')}>
URL
</Button>
<Button
type="button"
onClick={submit}
disabled={loading || submittable.length === 0}>
稿
</Button>
</div>
</div>
</div>
<PostImportRowDialog <PostImportRowDialog
open={editingRow != null} open={editingRow != null}
@@ -311,9 +291,42 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
setSearchParams ({ }) setSearchParams ({ })
}} }}
onSave={saveDraft}/> onSave={saveDraft}/>
</MainArea>) </>)
} }
const PostImportFooter = (
{ loading, invalidCount, submittableCount, onBack, onSubmit }: {
loading: boolean
invalidCount: number
submittableCount: 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">
<PostImportStatusBadge value="pending"/>
<span> {submittableCount} </span>
<PostImportStatusBadge value="error"/>
<span> {invalidCount} </span>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={onBack}>
URL
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={loading || submittableCount === 0}>
稿
</Button>
</div>
</div>
</div>)
const SummaryChip = ({ label, value }: { label: string const SummaryChip = ({ label, value }: { label: string
value: number }) => ( value: number }) => (
<div className="rounded-full border border-border bg-background px-3 py-1 text-sm"> <div className="rounded-full border border-border bg-background px-3 py-1 text-sm">
+26 -17
ファイルの表示
@@ -39,15 +39,12 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
const [source, setSource] = useState (draft.source) const [source, setSource] = useState (draft.source)
const [loading, setLoading] = useState (false) const [loading, setLoading] = useState (false)
const [error, setError] = useState<string | null> (null) const [sourceIssues, setSourceIssues] = useState<ReturnType<typeof validateImportSource>> ([])
const [touched, setTouched] = useState (false) const [sourceError, setSourceError] = useState<string | null> (null)
const saveTimer = useRef<number | null> (null) const saveTimer = useRef<number | null> (null)
const lineCount = countImportSourceLines (source) const lineCount = countImportSourceLines (source)
const issues = useMemo (() => validateImportSource (source), [source]) const messages = sourceError ? [sourceError] : []
const messages = [
...(((touched) && lineCount === 0) ? ['URL を入力してください.'] : []),
...(error ? [error] : [])]
useEffect (() => { useEffect (() => {
cleanupExpiredPostImportSessions (message => cleanupExpiredPostImportSessions (message =>
@@ -68,12 +65,24 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
}, [source]) }, [source])
const preview = async () => { const preview = async () => {
setTouched (true) if (lineCount === 0)
if (lineCount === 0 || issues.length > 0) {
return setSourceIssues ([])
setSourceError ('URL を入力してください.')
return
}
const issues = validateImportSource (source)
if (issues.length > 0)
{
setSourceIssues (issues)
setSourceError (null)
return
}
setLoading (true) setLoading (true)
setError (null) setSourceIssues ([])
setSourceError (null)
try try
{ {
const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', { const data = await apiPost<{ rows: PostImportRow[] }> ('/posts/import/preview', {
@@ -95,7 +104,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
? requestError.response?.data?.message ? requestError.response?.data?.message
?? requestError.response?.data?.baseErrors?.[0] ?? requestError.response?.data?.baseErrors?.[0]
: undefined : undefined
setError (message ?? '入力を確認してください.') setSourceError (message ?? '入力を確認してください.')
toast ({ toast ({
title: '投稿情報の取得に失敗しました', title: '投稿情報の取得に失敗しました',
description: message ?? '入力を確認してください.' }) description: message ?? '入力を確認してください.' })
@@ -126,11 +135,10 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
value={source} value={source}
rows={14} rows={14}
className={inputClass ( className={inputClass (
messages.length > 0 || issues.length > 0, messages.length > 0 || sourceIssues.length > 0,
'font-mono text-sm', 'font-mono text-sm',
)} )}
onBlur={() => { onBlur={() => {
setTouched (true)
savePostImportSourceDraft (source, message => savePostImportSourceDraft (source, message =>
toast ({ toast ({
title: '入力内容を保存できませんでした', title: '入力内容を保存できませんでした',
@@ -138,12 +146,13 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
}} }}
onChange={ev => { onChange={ev => {
setSource (ev.target.value) setSource (ev.target.value)
setError (null) setSourceError (null)
setSourceIssues ([])
}}/>)} }}/>)}
</FormField> </FormField>
<FieldError messages={messages}/> <FieldError messages={messages}/>
<div className="space-y-2"> <div className="space-y-2">
{issues.map (issue => ( {sourceIssues.map (issue => (
<div <div
key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`} key={`${ issue.sourceRow }-${ issue.message }-${ issue.url }`}
className="rounded-md border border-red-300 bg-red-50 p-3 text-sm className="rounded-md border border-red-300 bg-red-50 p-3 text-sm
@@ -163,8 +172,8 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
<Button <Button
type="button" type="button"
onClick={preview} onClick={preview}
disabled={loading || lineCount === 0 || issues.length > 0}> disabled={loading}>
稿
</Button> </Button>
</div> </div>
</div> </div>