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