広場投稿追加画面の刷新 (#399) #413
@@ -410,6 +410,20 @@ Good:
|
|||||||
- For user-facing Japanese kanji spelling, do not normalize to
|
- For user-facing Japanese kanji spelling, do not normalize to
|
||||||
《当用漢字による書きかえ》; prefer original forms such as `編輯`.
|
《当用漢字による書きかえ》; prefer original forms such as `編輯`.
|
||||||
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
|
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
|
||||||
|
- Frontend dialogue work must use `@/lib/dialogues/useDialogue` as the
|
||||||
|
feature-facing entrypoint.
|
||||||
|
- Reuse the existing common dialogue API and common dialogue component
|
||||||
|
instead of building feature-local overlay, close button, header, footer,
|
||||||
|
focus handling, outside click handling, Escape handling, or confirmation
|
||||||
|
flows.
|
||||||
|
- Do not import `@/components/ui/dialog` directly in feature code to build a
|
||||||
|
one-off dialogue, and do not evade this rule with aliases such as
|
||||||
|
`Dialog as Dialogue`.
|
||||||
|
- Keep business-specific form content in feature code, and keep the visual
|
||||||
|
and behavioural dialogue shell in common code.
|
||||||
|
- Use British spelling `Dialogue` for project-defined dialogue identifiers.
|
||||||
|
Keep an exact third-party API spelling only at the external boundary where
|
||||||
|
compatibility requires it.
|
||||||
|
|
||||||
### Frontend TypeScript and TSX style
|
### Frontend TypeScript and TSX style
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,22 @@ pass or the remaining failure is clearly blocked.
|
|||||||
third-party request outside the Rails API.
|
third-party request outside the Rails API.
|
||||||
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
|
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
|
||||||
|
|
||||||
|
## Dialogues
|
||||||
|
|
||||||
|
- Feature code must use `@/lib/dialogues/useDialogue` as the entrypoint for
|
||||||
|
dialogue work.
|
||||||
|
- Reuse the existing common dialogue API and common dialogue component.
|
||||||
|
- Do not import `@/components/ui/dialog` directly in feature code to build a
|
||||||
|
bespoke dialogue, and do not evade this rule with aliases such as
|
||||||
|
`Dialog as Dialogue`.
|
||||||
|
- Keep business-specific form content in feature code, and keep the visual
|
||||||
|
and behavioural dialogue shell in common code.
|
||||||
|
- Reuse the common confirmation flow from `useDialogue` instead of building a
|
||||||
|
feature-local confirmation dialogue.
|
||||||
|
- Use British spelling `Dialogue` for project-defined dialogue identifiers.
|
||||||
|
Keep an exact third-party API spelling only at the external boundary where
|
||||||
|
compatibility requires it.
|
||||||
|
|
||||||
## Imports and aliases
|
## Imports and aliases
|
||||||
|
|
||||||
- The `@` alias points to `frontend/src`.
|
- The `@` alias points to `frontend/src`.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: ReactNode
|
||||||
|
description?: ReactNode
|
||||||
|
className?: string
|
||||||
|
bodyClassName?: string
|
||||||
|
footerClassName?: string
|
||||||
|
footer?: ReactNode
|
||||||
|
children: ReactNode }
|
||||||
|
|
||||||
|
|
||||||
|
const CommonDialogue: FC<Props> = (
|
||||||
|
{ open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
bodyClassName,
|
||||||
|
footerClassName,
|
||||||
|
footer,
|
||||||
|
children },
|
||||||
|
) => (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className={cn ('px-6 pb-6 pt-7', className)}>
|
||||||
|
<DialogHeader className="pl-8">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
|
||||||
|
{description != null && (
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div>{description}</div>
|
||||||
|
</DialogDescription>)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className={bodyClassName}>{children}</div>
|
||||||
|
|
||||||
|
{footer != null && (
|
||||||
|
<DialogFooter className={footerClassName}>
|
||||||
|
{footer}
|
||||||
|
</DialogFooter>)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>)
|
||||||
|
|
||||||
|
export default CommonDialogue
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import CommonDialogue from '@/components/dialogues/CommonDialogue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -111,24 +106,18 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
<DialogueContext.Provider value={api}>
|
<DialogueContext.Provider value={api}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<Dialog
|
{active && (
|
||||||
|
<CommonDialogue
|
||||||
open={Boolean (active)}
|
open={Boolean (active)}
|
||||||
onOpenChange={open => {
|
onOpenChange={open => {
|
||||||
if (!(open))
|
if (!(open))
|
||||||
closeActive (active?.kind !== 'confirm' && null)
|
closeActive (active.kind !== 'confirm' && null)
|
||||||
}}>
|
}}
|
||||||
{active && (
|
title={active.options.title}
|
||||||
<DialogContent className="px-6 pb-6 pt-7">
|
description={active.options.description}
|
||||||
<DialogHeader className="pl-8">
|
bodyClassName="hidden"
|
||||||
<DialogTitle>{active.options.title}</DialogTitle>
|
footer={
|
||||||
|
<>
|
||||||
{active.options.description && (
|
|
||||||
<DialogDescription asChild>
|
|
||||||
<div>{active.options.description}</div>
|
|
||||||
</DialogDescription>)}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
{active.kind === 'confirm' && (
|
{active.kind === 'confirm' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -169,9 +158,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
{choice.label}
|
{choice.label}
|
||||||
</Button>))}
|
</Button>))}
|
||||||
</>)}
|
</>)}
|
||||||
</DialogFooter>
|
</>}/>
|
||||||
</DialogContent>)}
|
)}
|
||||||
</Dialog>
|
|
||||||
</DialogueContext.Provider>)
|
</DialogueContext.Provider>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
|
||||||
import FieldError from '@/components/common/FieldError'
|
|
||||||
import FieldWarning from '@/components/common/FieldWarning'
|
|
||||||
import FormField from '@/components/common/FormField'
|
|
||||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import { inputClass } from '@/lib/utils'
|
|
||||||
|
|
||||||
import type { FC } from 'react'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import type { PostImportResetSnapshot,
|
|
||||||
PostImportRow } from '@/lib/postImportSession'
|
|
||||||
|
|
||||||
type Draft = {
|
|
||||||
url: string
|
|
||||||
title: string
|
|
||||||
thumbnailBase: string
|
|
||||||
originalCreatedFrom: string
|
|
||||||
originalCreatedBefore: string
|
|
||||||
duration: string
|
|
||||||
tags: string
|
|
||||||
parentPostIds: string }
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
open: boolean
|
|
||||||
row: PostImportRow | null
|
|
||||||
messageRow?: PostImportRow | null
|
|
||||||
saving: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onSave: (
|
|
||||||
payload: { draft: Draft
|
|
||||||
resetRequested: boolean
|
|
||||||
resetSnapshot: PostImportResetSnapshot },
|
|
||||||
) => Promise<boolean> }
|
|
||||||
|
|
||||||
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 ?? ''),
|
|
||||||
duration: String (row.attributes.duration ?? ''),
|
|
||||||
tags: String (row.attributes.tags ?? ''),
|
|
||||||
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
|
||||||
|
|
||||||
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 ?? ''),
|
|
||||||
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
|
||||||
tags: String (row.resetSnapshot.attributes.tags ?? ''),
|
|
||||||
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
|
||||||
|
|
||||||
const groupedMessages = (
|
|
||||||
...values: Array<string[] | undefined>
|
|
||||||
): string[] =>
|
|
||||||
values.flatMap (value => value ?? [])
|
|
||||||
|
|
||||||
|
|
||||||
const PostImportRowDialog: FC<Props> = (
|
|
||||||
{ open, row, messageRow, saving, onOpenChange, onSave },
|
|
||||||
) => {
|
|
||||||
const [draft, setDraft] = useState<Draft | null> (null)
|
|
||||||
const [resetRequested, setResetRequested] = useState (false)
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (open && row != null)
|
|
||||||
{
|
|
||||||
setDraft (buildDraft (row))
|
|
||||||
setResetRequested (false)
|
|
||||||
}
|
|
||||||
}, [open, row])
|
|
||||||
|
|
||||||
if (row == null || draft == null)
|
|
||||||
return null
|
|
||||||
|
|
||||||
const displayRow = messageRow ?? row
|
|
||||||
|
|
||||||
const update = <Key extends keyof Draft,> (
|
|
||||||
key: Key,
|
|
||||||
value: Draft[Key],
|
|
||||||
) => {
|
|
||||||
setDraft (current =>
|
|
||||||
current != null ? { ...current, [key]: value } : current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (await onSave ({
|
|
||||||
draft,
|
|
||||||
resetRequested,
|
|
||||||
resetSnapshot: row.resetSnapshot }))
|
|
||||||
onOpenChange (false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="flex max-h-[calc(100dvh-1rem)] max-w-3xl flex-col
|
|
||||||
overflow-hidden p-0">
|
|
||||||
<DialogHeader className="shrink-0 pb-4 pl-14 pr-6 pt-7">
|
|
||||||
<DialogTitle>投稿を編輯</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
投稿 {row.sourceRow} の内容を確認し、必要な項目を編輯してください.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<DialogTextField
|
|
||||||
label="URL"
|
|
||||||
value={draft.url}
|
|
||||||
warnings={displayRow.fieldWarnings.url}
|
|
||||||
errors={groupedMessages (displayRow.validationErrors.url,
|
|
||||||
displayRow.importErrors?.url)}
|
|
||||||
onChange={value => update ('url', value)}/>
|
|
||||||
<DialogTextField
|
|
||||||
label="タイトル"
|
|
||||||
value={draft.title}
|
|
||||||
warnings={displayRow.fieldWarnings.title}
|
|
||||||
errors={groupedMessages (displayRow.validationErrors.title,
|
|
||||||
displayRow.importErrors?.title)}
|
|
||||||
onChange={value => update ('title', value)}/>
|
|
||||||
<DialogTextField
|
|
||||||
label="サムネール基底 URL"
|
|
||||||
value={draft.thumbnailBase}
|
|
||||||
warnings={displayRow.fieldWarnings.thumbnailBase}
|
|
||||||
errors={groupedMessages (
|
|
||||||
displayRow.validationErrors.thumbnailBase,
|
|
||||||
displayRow.importErrors?.thumbnailBase)}
|
|
||||||
onChange={value => update ('thumbnailBase', value)}/>
|
|
||||||
<PostOriginalCreatedTimeField
|
|
||||||
originalCreatedFrom={draft.originalCreatedFrom || null}
|
|
||||||
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
|
||||||
originalCreatedBefore={draft.originalCreatedBefore || null}
|
|
||||||
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
|
|
||||||
errors={groupedMessages (
|
|
||||||
displayRow.validationErrors.originalCreatedAt,
|
|
||||||
displayRow.validationErrors.originalCreatedFrom,
|
|
||||||
displayRow.validationErrors.originalCreatedBefore,
|
|
||||||
displayRow.importErrors?.originalCreatedAt,
|
|
||||||
displayRow.importErrors?.originalCreatedFrom,
|
|
||||||
displayRow.importErrors?.originalCreatedBefore)}/>
|
|
||||||
<DialogTextField
|
|
||||||
label="動画時間"
|
|
||||||
value={draft.duration}
|
|
||||||
errors={groupedMessages (
|
|
||||||
displayRow.validationErrors.duration,
|
|
||||||
displayRow.validationErrors.videoMs,
|
|
||||||
displayRow.importErrors?.duration,
|
|
||||||
displayRow.importErrors?.videoMs)}
|
|
||||||
onChange={value => update ('duration', value)}/>
|
|
||||||
<DialogAreaField
|
|
||||||
label="タグ"
|
|
||||||
value={draft.tags}
|
|
||||||
warnings={displayRow.fieldWarnings.tags}
|
|
||||||
errors={groupedMessages (displayRow.validationErrors.tags,
|
|
||||||
displayRow.importErrors?.tags)}
|
|
||||||
onChange={value => update ('tags', value)}/>
|
|
||||||
<DialogTextField
|
|
||||||
label="親投稿"
|
|
||||||
value={draft.parentPostIds}
|
|
||||||
errors={groupedMessages (
|
|
||||||
displayRow.validationErrors.parentPostIds,
|
|
||||||
displayRow.importErrors?.parentPostIds)}
|
|
||||||
onChange={value => update ('parentPostIds', value)}/>
|
|
||||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
|
||||||
<FieldError messages={displayRow.validationErrors.base}/>
|
|
||||||
<FieldError messages={displayRow.importErrors?.base}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="shrink-0 px-6 pb-6 pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setDraft (buildResetDraft (row))
|
|
||||||
setResetRequested (true)
|
|
||||||
}}>
|
|
||||||
変更をリセット
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange (false)}>
|
|
||||||
取消し
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={save}
|
|
||||||
disabled={saving}>
|
|
||||||
編輯内容を保存
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DialogTextField = (
|
|
||||||
{ label, value, warnings, errors, onChange }: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
warnings?: string[]
|
|
||||||
errors?: string[]
|
|
||||||
onChange: (value: string) => void },
|
|
||||||
) => (
|
|
||||||
<FormField label={label} 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 DialogAreaField = (
|
|
||||||
{ label, value, warnings, errors, onChange }: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
warnings?: string[]
|
|
||||||
errors?: string[]
|
|
||||||
onChange: (value: string) => void },
|
|
||||||
) => (
|
|
||||||
<FormField label={label} 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>)
|
|
||||||
|
|
||||||
export default PostImportRowDialog
|
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
|
import FieldError from '@/components/common/FieldError'
|
||||||
|
import FieldWarning from '@/components/common/FieldWarning'
|
||||||
|
import FormField from '@/components/common/FormField'
|
||||||
|
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||||
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { PostImportResetSnapshot,
|
||||||
|
PostImportRow } from '@/lib/postImportSession'
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
thumbnailBase: string
|
||||||
|
originalCreatedFrom: string
|
||||||
|
originalCreatedBefore: string
|
||||||
|
duration: string
|
||||||
|
tags: string
|
||||||
|
parentPostIds: string }
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
row: PostImportRow
|
||||||
|
messageRow?: PostImportRow | null
|
||||||
|
saving: boolean
|
||||||
|
onCancel: () => void
|
||||||
|
onSave: (
|
||||||
|
payload: { draft: Draft
|
||||||
|
resetRequested: boolean
|
||||||
|
resetSnapshot: PostImportResetSnapshot },
|
||||||
|
) => Promise<boolean> }
|
||||||
|
|
||||||
|
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 ?? ''),
|
||||||
|
duration: String (row.attributes.duration ?? ''),
|
||||||
|
tags: String (row.attributes.tags ?? ''),
|
||||||
|
parentPostIds: String (row.attributes.parentPostIds ?? '') })
|
||||||
|
|
||||||
|
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 ?? ''),
|
||||||
|
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
||||||
|
tags: String (row.resetSnapshot.attributes.tags ?? ''),
|
||||||
|
parentPostIds: String (row.resetSnapshot.attributes.parentPostIds ?? '') })
|
||||||
|
|
||||||
|
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
||||||
|
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.duration === right.duration
|
||||||
|
&& left.tags === right.tags
|
||||||
|
&& left.parentPostIds === right.parentPostIds
|
||||||
|
|
||||||
|
|
||||||
|
const PostImportRowForm: FC<Props> = (
|
||||||
|
{ row, messageRow, saving, onCancel, onSave },
|
||||||
|
) => {
|
||||||
|
const dialogue = useDialogue ()
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
|
||||||
|
const [resetRequested, setResetRequested] = useState (false)
|
||||||
|
|
||||||
|
const displayRow = messageRow ?? row
|
||||||
|
const resetDraft = buildResetDraft (row)
|
||||||
|
const resetDisabled = saving || sameDraft (draft, resetDraft)
|
||||||
|
|
||||||
|
const update = <Key extends keyof Draft,> (
|
||||||
|
key: Key,
|
||||||
|
value: Draft[Key],
|
||||||
|
) => {
|
||||||
|
setDraft (current => ({ ...current, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = async () => {
|
||||||
|
if (resetDisabled)
|
||||||
|
return
|
||||||
|
|
||||||
|
const confirmed = await dialogue.confirm ({
|
||||||
|
title: '変更をリセットしますか?',
|
||||||
|
description: '現在の URL に対する自動取得直後の内容へ戻します.',
|
||||||
|
confirmText: 'リセット',
|
||||||
|
cancelText: '取消',
|
||||||
|
variant: 'danger' })
|
||||||
|
if (!(confirmed))
|
||||||
|
return
|
||||||
|
|
||||||
|
setDraft (resetDraft)
|
||||||
|
setResetRequested (true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (await onSave ({
|
||||||
|
draft,
|
||||||
|
resetRequested,
|
||||||
|
resetSnapshot: row.resetSnapshot }))
|
||||||
|
onCancel ()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PostImportTextField
|
||||||
|
label="URL"
|
||||||
|
value={draft.url}
|
||||||
|
warnings={displayRow.fieldWarnings.url}
|
||||||
|
errors={groupedMessages (
|
||||||
|
displayRow.validationErrors.url,
|
||||||
|
displayRow.importErrors?.url)}
|
||||||
|
onChange={value => update ('url', value)}/>
|
||||||
|
<PostImportTextField
|
||||||
|
label="タイトル"
|
||||||
|
value={draft.title}
|
||||||
|
warnings={displayRow.fieldWarnings.title}
|
||||||
|
errors={groupedMessages (
|
||||||
|
displayRow.validationErrors.title,
|
||||||
|
displayRow.importErrors?.title)}
|
||||||
|
onChange={value => update ('title', value)}/>
|
||||||
|
<PostImportTextField
|
||||||
|
label="サムネール基底 URL"
|
||||||
|
value={draft.thumbnailBase}
|
||||||
|
warnings={displayRow.fieldWarnings.thumbnailBase}
|
||||||
|
errors={groupedMessages (
|
||||||
|
displayRow.validationErrors.thumbnailBase,
|
||||||
|
displayRow.importErrors?.thumbnailBase)}
|
||||||
|
onChange={value => update ('thumbnailBase', value)}/>
|
||||||
|
<PostOriginalCreatedTimeField
|
||||||
|
originalCreatedFrom={draft.originalCreatedFrom || null}
|
||||||
|
setOriginalCreatedFrom={value => update ('originalCreatedFrom', value ?? '')}
|
||||||
|
originalCreatedBefore={draft.originalCreatedBefore || null}
|
||||||
|
setOriginalCreatedBefore={value => update ('originalCreatedBefore', value ?? '')}
|
||||||
|
errors={groupedMessages (
|
||||||
|
displayRow.validationErrors.originalCreatedAt,
|
||||||
|
displayRow.validationErrors.originalCreatedFrom,
|
||||||
|
displayRow.validationErrors.originalCreatedBefore,
|
||||||
|
displayRow.importErrors?.originalCreatedAt,
|
||||||
|
displayRow.importErrors?.originalCreatedFrom,
|
||||||
|
displayRow.importErrors?.originalCreatedBefore)}/>
|
||||||
|
<PostImportTextField
|
||||||
|
label="動画時間"
|
||||||
|
value={draft.duration}
|
||||||
|
errors={groupedMessages (
|
||||||
|
displayRow.validationErrors.duration,
|
||||||
|
displayRow.validationErrors.videoMs,
|
||||||
|
displayRow.importErrors?.duration,
|
||||||
|
displayRow.importErrors?.videoMs)}
|
||||||
|
onChange={value => update ('duration', value)}/>
|
||||||
|
<PostImportAreaField
|
||||||
|
label="タグ"
|
||||||
|
value={draft.tags}
|
||||||
|
warnings={displayRow.fieldWarnings.tags}
|
||||||
|
errors={groupedMessages (
|
||||||
|
displayRow.validationErrors.tags,
|
||||||
|
displayRow.importErrors?.tags)}
|
||||||
|
onChange={value => update ('tags', value)}/>
|
||||||
|
<PostImportTextField
|
||||||
|
label="親投稿"
|
||||||
|
value={draft.parentPostIds}
|
||||||
|
errors={groupedMessages (
|
||||||
|
displayRow.validationErrors.parentPostIds,
|
||||||
|
displayRow.importErrors?.parentPostIds)}
|
||||||
|
onChange={value => update ('parentPostIds', value)}/>
|
||||||
|
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||||
|
<FieldError messages={displayRow.validationErrors.base}/>
|
||||||
|
<FieldError messages={displayRow.importErrors?.base}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col-reverse gap-2 px-6 pb-6 pt-4 sm:flex-row sm:justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => reset ()}
|
||||||
|
disabled={resetDisabled}>
|
||||||
|
変更をリセット
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => save ()}
|
||||||
|
disabled={saving}>
|
||||||
|
編輯内容を保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostImportTextField = (
|
||||||
|
{ label, value, warnings, errors, onChange }: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
warnings?: string[]
|
||||||
|
errors?: string[]
|
||||||
|
onChange: (value: string) => void },
|
||||||
|
) => (
|
||||||
|
<FormField label={label} 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 PostImportAreaField = (
|
||||||
|
{ label, value, warnings, errors, onChange }: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
warnings?: string[]
|
||||||
|
errors?: string[]
|
||||||
|
onChange: (value: string) => void },
|
||||||
|
) => (
|
||||||
|
<FormField label={label} 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>)
|
||||||
|
|
||||||
|
export default PostImportRowForm
|
||||||
|
export type { Draft as PostImportRowDraft }
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||||
@@ -2,9 +2,10 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import CommonDialogue from '@/components/dialogues/CommonDialogue'
|
||||||
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 PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||||
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
import PostImportRowSummary from '@/components/posts/import/PostImportRowSummary'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
@@ -23,6 +24,7 @@ import Forbidden from '@/pages/Forbidden'
|
|||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { PostImportRowDraft } from '@/components/posts/import/PostImportRowForm'
|
||||||
import type { PostImportResultRow,
|
import type { PostImportResultRow,
|
||||||
PostImportRow,
|
PostImportRow,
|
||||||
PostImportSession } from '@/lib/postImportSession'
|
PostImportSession } from '@/lib/postImportSession'
|
||||||
@@ -30,16 +32,6 @@ import type { User } from '@/types'
|
|||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|
||||||
type Draft = {
|
|
||||||
url: string
|
|
||||||
title: string
|
|
||||||
thumbnailBase: string
|
|
||||||
originalCreatedFrom: string
|
|
||||||
originalCreatedBefore: string
|
|
||||||
duration: string
|
|
||||||
tags: string
|
|
||||||
parentPostIds: string }
|
|
||||||
|
|
||||||
|
|
||||||
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
const PostImportReviewPage: FC<Props> = ({ user }) => {
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
@@ -51,7 +43,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
const [missing, setMissing] = useState (false)
|
const [missing, setMissing] = useState (false)
|
||||||
const [savingRow, setSavingRow] = useState<number | null> (null)
|
const [savingRow, setSavingRow] = useState<number | null> (null)
|
||||||
const [dialogMessageRow, setDialogMessageRow] = useState<PostImportRow | null> (null)
|
const [dialogueMessageRow, setDialogueMessageRow] = useState<PostImportRow | null> (null)
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (sessionId == null)
|
if (sessionId == null)
|
||||||
@@ -105,12 +97,12 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (editingRow == null)
|
if (editingRow == null)
|
||||||
{
|
{
|
||||||
setDialogMessageRow (null)
|
setDialogueMessageRow (null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (dialogMessageRow?.sourceRow !== editingRow.sourceRow)
|
if (dialogueMessageRow?.sourceRow !== editingRow.sourceRow)
|
||||||
setDialogMessageRow (null)
|
setDialogueMessageRow (null)
|
||||||
}, [editingRow, dialogMessageRow])
|
}, [editingRow, dialogueMessageRow])
|
||||||
|
|
||||||
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||||
setSession (current =>
|
setSession (current =>
|
||||||
@@ -118,7 +110,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const saveDraft = async (
|
const saveDraft = async (
|
||||||
{ draft, resetRequested, resetSnapshot }: {
|
{ draft, resetRequested, resetSnapshot }: {
|
||||||
draft: Draft
|
draft: PostImportRowDraft
|
||||||
resetRequested: boolean
|
resetRequested: boolean
|
||||||
resetSnapshot: PostImportRow['resetSnapshot'] },
|
resetSnapshot: PostImportRow['resetSnapshot'] },
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
@@ -166,11 +158,11 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const target = validatedRows.find (row => row.sourceRow === editingRow.sourceRow)
|
const target = validatedRows.find (row => row.sourceRow === editingRow.sourceRow)
|
||||||
if (target != null && Object.keys (target.validationErrors).length > 0)
|
if (target != null && Object.keys (target.validationErrors).length > 0)
|
||||||
{
|
{
|
||||||
setDialogMessageRow (target)
|
setDialogueMessageRow (target)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
||||||
setDialogMessageRow (null)
|
setDialogueMessageRow (null)
|
||||||
setSearchParams ({ })
|
setSearchParams ({ })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -209,7 +201,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (firstInvalid != null)
|
if (firstInvalid != null)
|
||||||
{
|
{
|
||||||
setSession ({ ...session, rows: mergedRows })
|
setSession ({ ...session, rows: mergedRows })
|
||||||
setDialogMessageRow (firstInvalid)
|
setDialogueMessageRow (firstInvalid)
|
||||||
setSearchParams ({ edit: String (firstInvalid.sourceRow) })
|
setSearchParams ({ edit: String (firstInvalid.sourceRow) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -250,7 +242,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
rows: nextRows,
|
rows: nextRows,
|
||||||
repairMode: 'failed' as const }
|
repairMode: 'failed' as const }
|
||||||
setSession (repairSession)
|
setSession (repairSession)
|
||||||
setDialogMessageRow (firstRecoverable)
|
setDialogueMessageRow (firstRecoverable)
|
||||||
const saved = savePostImportSession (sessionId, repairSession, message =>
|
const saved = savePostImportSession (sessionId, repairSession, message =>
|
||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
if (saved)
|
if (saved)
|
||||||
@@ -323,16 +315,25 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
onBack={() => navigate ('/posts/import')}
|
onBack={() => navigate ('/posts/import')}
|
||||||
onSubmit={submit}/>
|
onSubmit={submit}/>
|
||||||
|
|
||||||
<PostImportRowDialog
|
{editingRow != null && (
|
||||||
open={editingRow != null}
|
<CommonDialogue
|
||||||
row={editingRow}
|
open={true}
|
||||||
messageRow={dialogMessageRow}
|
|
||||||
saving={savingRow != null}
|
|
||||||
onOpenChange={open => {
|
onOpenChange={open => {
|
||||||
if (!open)
|
if (!open)
|
||||||
setSearchParams ({ })
|
setSearchParams ({ })
|
||||||
}}
|
}}
|
||||||
|
title="投稿を編輯"
|
||||||
|
description={`投稿 ${ editingRow.sourceRow } の内容を確認し、必要な項目を編輯してください.`}
|
||||||
|
className="flex max-h-[calc(100dvh-1rem)] max-w-3xl flex-col overflow-hidden p-0"
|
||||||
|
bodyClassName="contents">
|
||||||
|
<PostImportRowForm
|
||||||
|
key={editingRow.sourceRow}
|
||||||
|
row={editingRow}
|
||||||
|
messageRow={dialogueMessageRow}
|
||||||
|
saving={savingRow != null}
|
||||||
|
onCancel={() => setSearchParams ({ })}
|
||||||
onSave={saveDraft}/>
|
onSave={saveDraft}/>
|
||||||
|
</CommonDialogue>)}
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +376,7 @@ const PostImportFooter = (
|
|||||||
|
|
||||||
const buildNextEditedRow = (
|
const buildNextEditedRow = (
|
||||||
editingRow: PostImportRow,
|
editingRow: PostImportRow,
|
||||||
draft: Draft,
|
draft: PostImportRowDraft,
|
||||||
urlChanged: boolean,
|
urlChanged: boolean,
|
||||||
): PostImportRow => {
|
): PostImportRow => {
|
||||||
const nextProvenance = { ...editingRow.provenance }
|
const nextProvenance = { ...editingRow.provenance }
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする