このコミットが含まれているのは:
@@ -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'
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>)
|
</>)
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする