広場投稿追加画面の刷新 (#399) #413
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog,
|
import { Dialog,
|
||||||
@@ -7,29 +7,16 @@ import { Dialog,
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { DialogueContext, useDialogue } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
import type { AlertOptions,
|
||||||
type DialogueVariant = 'default' | 'danger'
|
ChoiceOptions,
|
||||||
|
ConfirmOptions,
|
||||||
type ConfirmOptions = { title: string
|
DialogueAPI,
|
||||||
description?: ReactNode
|
DialogueFormAction,
|
||||||
confirmText?: string
|
DialogueFormControls,
|
||||||
cancelText?: string
|
DialogueFormOptions } from '@/lib/dialogues/useDialogue'
|
||||||
variant?: DialogueVariant }
|
|
||||||
|
|
||||||
type AlertOptions = { title: string
|
|
||||||
description?: ReactNode
|
|
||||||
okText?: string }
|
|
||||||
|
|
||||||
type Choice<T extends string> = { value: T
|
|
||||||
label: string
|
|
||||||
variant?: DialogueVariant }
|
|
||||||
|
|
||||||
type ChoiceOptions<T extends string> = { title: string
|
|
||||||
description?: ReactNode
|
|
||||||
choices: Choice<T>[]
|
|
||||||
cancelText?: string }
|
|
||||||
|
|
||||||
type DialogueRequest =
|
type DialogueRequest =
|
||||||
| { id: number
|
| { id: number
|
||||||
@@ -44,13 +31,10 @@ type DialogueRequest =
|
|||||||
kind: 'choice'
|
kind: 'choice'
|
||||||
options: ChoiceOptions<string>
|
options: ChoiceOptions<string>
|
||||||
resolve: (value: string | null) => void }
|
resolve: (value: string | null) => void }
|
||||||
|
| { id: number
|
||||||
type DialogueAPI =
|
kind: 'form'
|
||||||
{ confirm: (options: ConfirmOptions) => Promise<boolean>
|
options: DialogueFormOptions
|
||||||
alert: (options: AlertOptions) => Promise<void>
|
resolve: () => void }
|
||||||
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }
|
|
||||||
|
|
||||||
const DialogueContext = createContext<DialogueAPI | null> (null)
|
|
||||||
|
|
||||||
let nextDialogueId = 1
|
let nextDialogueId = 1
|
||||||
|
|
||||||
@@ -58,21 +42,24 @@ type Props = { children: ReactNode }
|
|||||||
|
|
||||||
|
|
||||||
const DialogueProvider: FC<Props> = ({ children }) => {
|
const DialogueProvider: FC<Props> = ({ children }) => {
|
||||||
const [queue, setQueue] = useState<DialogueRequest[]> ([])
|
const [stack, setStack] = useState<DialogueRequest[]> ([])
|
||||||
|
const [pendingIds, setPendingIds] = useState<number[]> ([])
|
||||||
|
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
|
||||||
|
const formControls = useRef<Record<number, DialogueFormControls>> ({ })
|
||||||
|
|
||||||
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
|
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
|
||||||
const id = nextDialogueId
|
const id = nextDialogueId
|
||||||
++nextDialogueId
|
++nextDialogueId
|
||||||
|
|
||||||
setQueue (q => [...q, { ...request, id } as DialogueRequest])
|
setStack (current => [...current, { ...request, id } as DialogueRequest])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const closeActive = useCallback ((result?: unknown) => {
|
const closeRequest = useCallback ((id: number, result?: unknown) => {
|
||||||
setQueue (q => {
|
setStack (current => {
|
||||||
const [active, ...rest] = q
|
const active = current.find (request => request.id === id)
|
||||||
|
|
||||||
if (!(active))
|
if (active == null)
|
||||||
return rest
|
return current
|
||||||
|
|
||||||
switch (active.kind)
|
switch (active.kind)
|
||||||
{
|
{
|
||||||
@@ -87,12 +74,28 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
case 'choice':
|
case 'choice':
|
||||||
active.resolve ((result ?? null) as string | null)
|
active.resolve ((result ?? null) as string | null)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'form':
|
||||||
|
active.resolve ()
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return current.filter (request => request.id !== id)
|
||||||
|
})
|
||||||
|
setPendingIds (current => current.filter (_1 => _1 !== id))
|
||||||
|
setFormActions (current => {
|
||||||
|
const { [id]: _, ...rest } = current
|
||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
|
delete formControls.current[id]
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const setRequestActions = useCallback (
|
||||||
|
(id: number, actions: DialogueFormAction[]) => {
|
||||||
|
setFormActions (current => ({ ...current, [id]: actions }))
|
||||||
|
},
|
||||||
|
[])
|
||||||
|
|
||||||
const api = useMemo<DialogueAPI> (() => ({
|
const api = useMemo<DialogueAPI> (() => ({
|
||||||
confirm: options => new Promise<boolean> (resolve => {
|
confirm: options => new Promise<boolean> (resolve => {
|
||||||
push ({ kind: 'confirm', options, resolve })
|
push ({ kind: 'confirm', options, resolve })
|
||||||
@@ -103,86 +106,164 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
choice: options => new Promise (resolve => {
|
choice: options => new Promise (resolve => {
|
||||||
push ({ kind: 'choice',
|
push ({ kind: 'choice',
|
||||||
options: options as ChoiceOptions<string>,
|
options: options as ChoiceOptions<string>,
|
||||||
resolve: resolve as (value: string | null) => void })}) }), [push])
|
resolve: resolve as (value: string | null) => void })
|
||||||
|
}),
|
||||||
|
form: options => new Promise<void> (resolve => {
|
||||||
|
push ({ kind: 'form', options, resolve })
|
||||||
|
}) }), [push])
|
||||||
|
|
||||||
const active = queue[0]
|
const handleFormAction = useCallback (
|
||||||
|
async (id: number, action: DialogueFormAction) => {
|
||||||
|
if (pendingIds.includes (id))
|
||||||
|
return
|
||||||
|
|
||||||
|
setPendingIds (current => [...current, id])
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const shouldClose = await action.onSelect ()
|
||||||
|
if (shouldClose !== false)
|
||||||
|
closeRequest (id)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setPendingIds (current => current.filter (_1 => _1 !== id))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[closeRequest, pendingIds])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogueContext.Provider value={api}>
|
<DialogueContext.Provider value={api}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<Dialog
|
{stack.map ((request, index) => {
|
||||||
open={Boolean (active)}
|
const isTop = index === stack.length - 1
|
||||||
onOpenChange={open => {
|
const pending = pendingIds.includes (request.id)
|
||||||
if (!(open))
|
const controls =
|
||||||
closeActive (active?.kind !== 'confirm' && null)
|
request.kind === 'form'
|
||||||
}}>
|
? (formControls.current[request.id] ??= {
|
||||||
{active && (
|
close: () => closeRequest (request.id),
|
||||||
<DialogContent className="px-6 pb-6 pt-7">
|
setActions: actions => setRequestActions (request.id, actions) })
|
||||||
<DialogHeader className="pl-8">
|
: null
|
||||||
<DialogTitle>{active.options.title}</DialogTitle>
|
const requestActions = formActions[request.id] ?? []
|
||||||
|
|
||||||
{active.options.description && (
|
return (
|
||||||
|
<Dialog
|
||||||
|
key={request.id}
|
||||||
|
open
|
||||||
|
onOpenChange={open => {
|
||||||
|
if (!(open) && isTop && !(pending))
|
||||||
|
closeRequest (request.id, request.kind !== 'confirm' && null)
|
||||||
|
}}>
|
||||||
|
<DialogContent
|
||||||
|
className={
|
||||||
|
request.kind === 'form'
|
||||||
|
? `flex max-h-[calc(100dvh-1rem)] ${ request.options.size === 'large'
|
||||||
|
? 'max-w-3xl'
|
||||||
|
: 'max-w-lg' } flex-col overflow-hidden gap-0`
|
||||||
|
: 'px-6 pb-6 pt-7'
|
||||||
|
}
|
||||||
|
onEscapeKeyDown={event => {
|
||||||
|
if (pending)
|
||||||
|
event.preventDefault ()
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={event => {
|
||||||
|
if (pending)
|
||||||
|
event.preventDefault ()
|
||||||
|
}}>
|
||||||
|
{request.kind === 'form'
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="shrink-0 pl-8 pr-0 pt-1 text-left">
|
||||||
|
<DialogTitle>{request.options.title}</DialogTitle>
|
||||||
|
|
||||||
|
{request.options.description && (
|
||||||
<DialogDescription asChild>
|
<DialogDescription asChild>
|
||||||
<div>{active.options.description}</div>
|
<div>{request.options.description}</div>
|
||||||
|
</DialogDescription>)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
{controls && request.options.body (controls)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => closeRequest (request.id)}
|
||||||
|
disabled={pending}>
|
||||||
|
{request.options.cancelText ?? '取消'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{requestActions.map (action => (
|
||||||
|
<Button
|
||||||
|
key={action.label}
|
||||||
|
variant={action.variant === 'danger'
|
||||||
|
? 'destructive'
|
||||||
|
: 'default'}
|
||||||
|
onClick={() => void handleFormAction (request.id, action)}
|
||||||
|
disabled={pending || action.disabled}>
|
||||||
|
{action.label}
|
||||||
|
</Button>))}
|
||||||
|
</DialogFooter>
|
||||||
|
</>)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="pl-8">
|
||||||
|
<DialogTitle>{request.options.title}</DialogTitle>
|
||||||
|
|
||||||
|
{request.options.description && (
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div>{request.options.description}</div>
|
||||||
</DialogDescription>)}
|
</DialogDescription>)}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{active.kind === 'confirm' && (
|
{request.kind === 'confirm' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => closeActive (false)}>
|
onClick={() => closeRequest (request.id, false)}>
|
||||||
{active.options.cancelText ?? '取消'}
|
{request.options.cancelText ?? '取消'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={(active.options.variant === 'danger')
|
variant={(request.options.variant === 'danger')
|
||||||
? 'destructive'
|
? 'destructive'
|
||||||
: 'default'}
|
: 'default'}
|
||||||
onClick={() => closeActive (true)}>
|
onClick={() => closeRequest (request.id, true)}>
|
||||||
{active.options.confirmText ?? '確定'}
|
{request.options.confirmText ?? '確定'}
|
||||||
</Button>
|
</Button>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{active.kind === 'alert' && (
|
{request.kind === 'alert' && (
|
||||||
<Button onClick={() => closeActive ()}>
|
<Button onClick={() => closeRequest (request.id)}>
|
||||||
{active.options.okText ?? '確定'}
|
{request.options.okText ?? '確定'}
|
||||||
</Button>)}
|
</Button>)}
|
||||||
|
|
||||||
{active.kind === 'choice' && (
|
{request.kind === 'choice' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => closeActive (null)}>
|
onClick={() => closeRequest (request.id, null)}>
|
||||||
{active.options.cancelText ?? '取消'}
|
{request.options.cancelText ?? '取消'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{active.options.choices.map (choice => (
|
{request.options.choices.map (choice => (
|
||||||
<Button
|
<Button
|
||||||
key={choice.value}
|
key={choice.value}
|
||||||
variant={(choice.variant === 'danger')
|
variant={(choice.variant === 'danger')
|
||||||
? 'destructive'
|
? 'destructive'
|
||||||
: 'default'}
|
: 'default'}
|
||||||
onClick={() => closeActive (choice.value)}>
|
onClick={() => closeRequest (request.id, choice.value)}>
|
||||||
{choice.label}
|
{choice.label}
|
||||||
</Button>))}
|
</Button>))}
|
||||||
</>)}
|
</>)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>)}
|
</>)}
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
|
</Dialog>)
|
||||||
|
})}
|
||||||
</DialogueContext.Provider>)
|
</DialogueContext.Provider>)
|
||||||
}
|
}
|
||||||
|
export { useDialogue }
|
||||||
|
|
||||||
export const useDialogue = () => {
|
|
||||||
const dialogue = useContext (DialogueContext)
|
|
||||||
|
|
||||||
if (!(dialogue))
|
|
||||||
throw new Error ('useDialogue must be used inside DialogueProvider')
|
|
||||||
|
|
||||||
return dialogue
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DialogueProvider
|
export default DialogueProvider
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
import FieldWarning from '@/components/common/FieldWarning'
|
import FieldWarning from '@/components/common/FieldWarning'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
import ThumbnailPreview from '@/components/posts/import/ThumbnailPreview'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { PostImportRow } from '@/lib/postImportSession'
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
type Draft = {
|
type Draft = {
|
||||||
url: string
|
url: string
|
||||||
@@ -25,10 +25,11 @@ type Draft = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
row: PostImportRow
|
row: PostImportRow
|
||||||
messageRow?: PostImportRow | null
|
controls: DialogueFormControls
|
||||||
saving: boolean
|
onSave: (args: { draft: Draft
|
||||||
onDraftChange: Dispatch<SetStateAction<Draft | null>>
|
resetRequested: boolean }) => Promise<{
|
||||||
onResetRequestedChange: Dispatch<SetStateAction<boolean>> }
|
saved: boolean
|
||||||
|
row: PostImportRow | null }> }
|
||||||
|
|
||||||
const buildDraft = (row: PostImportRow): Draft => ({
|
const buildDraft = (row: PostImportRow): Draft => ({
|
||||||
url: row.url,
|
url: row.url,
|
||||||
@@ -66,22 +67,21 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
|
|||||||
|
|
||||||
const PostImportRowForm: FC<Props> = (
|
const PostImportRowForm: FC<Props> = (
|
||||||
{ row,
|
{ row,
|
||||||
messageRow,
|
controls,
|
||||||
saving,
|
onSave },
|
||||||
onDraftChange,
|
|
||||||
onResetRequestedChange },
|
|
||||||
) => {
|
) => {
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
|
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
|
||||||
|
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
|
||||||
|
const [saving, setSaving] = useState (false)
|
||||||
const [resetRequested, setResetRequested] = useState (false)
|
const [resetRequested, setResetRequested] = useState (false)
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const nextDraft = buildDraft (row)
|
const nextDraft = buildDraft (row)
|
||||||
setDraft (nextDraft)
|
setDraft (nextDraft)
|
||||||
|
setMessageRow (null)
|
||||||
setResetRequested (false)
|
setResetRequested (false)
|
||||||
onDraftChange (nextDraft)
|
}, [row])
|
||||||
onResetRequestedChange (false)
|
|
||||||
}, [onDraftChange, onResetRequestedChange, row])
|
|
||||||
|
|
||||||
const displayRow = messageRow ?? row
|
const displayRow = messageRow ?? row
|
||||||
const resetDraft = buildResetDraft (row)
|
const resetDraft = buildResetDraft (row)
|
||||||
@@ -91,16 +91,14 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
key: Key,
|
key: Key,
|
||||||
value: Draft[Key],
|
value: Draft[Key],
|
||||||
) => {
|
) => {
|
||||||
setDraft (current => {
|
if (messageRow != null)
|
||||||
const next = { ...current, [key]: value }
|
setMessageRow (null)
|
||||||
onDraftChange (next)
|
setDraft (current => ({ ...current, [key]: value }))
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = async () => {
|
const reset = useCallback (async (): Promise<boolean> => {
|
||||||
if (resetDisabled)
|
if (resetDisabled)
|
||||||
return
|
return false
|
||||||
|
|
||||||
const confirmed = await dialogue.confirm ({
|
const confirmed = await dialogue.confirm ({
|
||||||
title: '変更をリセットしますか?',
|
title: '変更をリセットしますか?',
|
||||||
@@ -109,22 +107,48 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
variant: 'danger' })
|
variant: 'danger' })
|
||||||
if (!(confirmed))
|
if (!(confirmed))
|
||||||
return
|
return false
|
||||||
|
|
||||||
setDraft (resetDraft)
|
setDraft (resetDraft)
|
||||||
setResetRequested (true)
|
setResetRequested (true)
|
||||||
onDraftChange (resetDraft)
|
setMessageRow (null)
|
||||||
onResetRequestedChange (true)
|
return false
|
||||||
|
}, [dialogue, resetDisabled, resetDraft])
|
||||||
|
|
||||||
|
const save = useCallback (async (): Promise<boolean> => {
|
||||||
|
setSaving (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const result = await onSave ({ draft, resetRequested })
|
||||||
|
if (result.saved)
|
||||||
|
return true
|
||||||
|
|
||||||
|
if (result.row != null)
|
||||||
|
setMessageRow (result.row)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setSaving (false)
|
||||||
|
}
|
||||||
|
}, [draft, onSave, resetRequested])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
controls.setActions ([{
|
||||||
|
label: '変更をリセット',
|
||||||
|
variant: 'danger',
|
||||||
|
disabled: resetDisabled,
|
||||||
|
onSelect: reset },
|
||||||
|
{
|
||||||
|
label: '編輯内容を保存',
|
||||||
|
disabled: saving,
|
||||||
|
onSelect: save }])
|
||||||
|
}, [controls, resetDisabled, reset, save, saving])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-6">
|
<div className="px-6 pb-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
投稿 {row.sourceRow} の内容を確認し、必要な項目を編輯してください.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<ThumbnailPreview
|
<ThumbnailPreview
|
||||||
@@ -196,16 +220,6 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
<FieldWarning messages={displayRow.baseWarnings}/>
|
||||||
<FieldError messages={displayRow.validationErrors.base}/>
|
<FieldError messages={displayRow.validationErrors.base}/>
|
||||||
<FieldError messages={displayRow.importErrors?.base}/>
|
<FieldError messages={displayRow.importErrors?.base}/>
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => reset ()}
|
|
||||||
disabled={resetDisabled}>
|
|
||||||
変更をリセット
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,4 +273,3 @@ const PostImportAreaField = (
|
|||||||
export default PostImportRowForm
|
export default PostImportRowForm
|
||||||
export { buildDraft }
|
export { buildDraft }
|
||||||
export type { Draft as PostImportRowDraft }
|
export type { Draft as PostImportRowDraft }
|
||||||
export type { Draft as PostImportRowDraft }
|
|
||||||
|
|||||||
@@ -1,5 +1,70 @@
|
|||||||
import { useDialogue as useDialogueFromProvider } from '@/components/dialogues/DialogueProvider'
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
export const useDialogue = () => useDialogueFromProvider ()
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type DialogueVariant = 'default' | 'danger'
|
||||||
|
|
||||||
|
type ConfirmOptions = { title: string
|
||||||
|
description?: ReactNode
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
variant?: DialogueVariant }
|
||||||
|
|
||||||
|
type AlertOptions = { title: string
|
||||||
|
description?: ReactNode
|
||||||
|
okText?: string }
|
||||||
|
|
||||||
|
type Choice<T extends string> = { value: T
|
||||||
|
label: string
|
||||||
|
variant?: DialogueVariant }
|
||||||
|
|
||||||
|
type ChoiceOptions<T extends string> = { title: string
|
||||||
|
description?: ReactNode
|
||||||
|
choices: Choice<T>[]
|
||||||
|
cancelText?: string }
|
||||||
|
|
||||||
|
type DialogueFormAction = {
|
||||||
|
label: string
|
||||||
|
variant?: DialogueVariant
|
||||||
|
disabled?: boolean
|
||||||
|
onSelect: () => Promise<boolean | void> | boolean | void }
|
||||||
|
|
||||||
|
type DialogueFormControls = {
|
||||||
|
close: () => void
|
||||||
|
setActions: (actions: DialogueFormAction[]) => void }
|
||||||
|
|
||||||
|
type DialogueFormOptions = { title: string
|
||||||
|
description?: ReactNode
|
||||||
|
body: (controls: DialogueFormControls) => ReactNode
|
||||||
|
cancelText?: string
|
||||||
|
size?: 'default' | 'large' }
|
||||||
|
|
||||||
|
type DialogueAPI =
|
||||||
|
{ confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||||
|
alert: (options: AlertOptions) => Promise<void>
|
||||||
|
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null>
|
||||||
|
form: (options: DialogueFormOptions) => Promise<void> }
|
||||||
|
|
||||||
|
const DialogueContext = createContext<DialogueAPI | null> (null)
|
||||||
|
|
||||||
|
const useDialogue = () => {
|
||||||
|
const dialogue = useContext (DialogueContext)
|
||||||
|
|
||||||
|
if (dialogue == null)
|
||||||
|
throw new Error ('useDialogue must be used inside DialogueProvider')
|
||||||
|
|
||||||
|
return dialogue
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DialogueContext, useDialogue }
|
||||||
export default useDialogue
|
export default useDialogue
|
||||||
|
export type {
|
||||||
|
AlertOptions,
|
||||||
|
Choice,
|
||||||
|
ChoiceOptions,
|
||||||
|
ConfirmOptions,
|
||||||
|
DialogueAPI,
|
||||||
|
DialogueFormAction,
|
||||||
|
DialogueFormControls,
|
||||||
|
DialogueFormOptions,
|
||||||
|
DialogueVariant }
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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 PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PostImportRowForm, { buildDraft } from '@/components/posts/import/PostImportRowForm'
|
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'
|
||||||
@@ -43,12 +43,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const [session, setSession] = useState<PostImportSession | null> (null)
|
const [session, setSession] = useState<PostImportSession | null> (null)
|
||||||
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 [dialogueMessageRow, setDialogueMessageRow] = useState<PostImportRow | null> (null)
|
|
||||||
const [dialogueDraft, setDialogueDraft] = useState<PostImportRowDraft | null> (null)
|
|
||||||
const [dialogueResetRequested, setDialogueResetRequested] = useState (false)
|
|
||||||
const dialogueDraftRef = useRef<PostImportRowDraft | null> (null)
|
|
||||||
const dialogueResetRequestedRef = useRef (false)
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (sessionId == null)
|
if (sessionId == null)
|
||||||
@@ -68,14 +62,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
toast ({ title: '取込状態を保存できませんでした', description: message }))
|
||||||
}, [session, sessionId])
|
}, [session, sessionId])
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
dialogueDraftRef.current = dialogueDraft
|
|
||||||
}, [dialogueDraft])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
dialogueResetRequestedRef.current = dialogueResetRequested
|
|
||||||
}, [dialogueResetRequested])
|
|
||||||
|
|
||||||
const rows = session?.rows ?? []
|
const rows = session?.rows ?? []
|
||||||
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
const editingSourceRow = Number (searchParams.get ('edit') ?? '')
|
||||||
const editingRow =
|
const editingRow =
|
||||||
@@ -107,21 +93,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
element?.scrollIntoView ({ block: 'center', behavior: 'smooth' })
|
||||||
}, [editingRow, session?.repairMode])
|
}, [editingRow, session?.repairMode])
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (editingRow == null)
|
|
||||||
{
|
|
||||||
setDialogueMessageRow (null)
|
|
||||||
setDialogueDraft (null)
|
|
||||||
setDialogueResetRequested (false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (dialogueMessageRow?.sourceRow !== editingRow.sourceRow)
|
|
||||||
setDialogueMessageRow (null)
|
|
||||||
setDialogueDraft (current =>
|
|
||||||
current != null ? current : buildDraft (editingRow))
|
|
||||||
setDialogueResetRequested (false)
|
|
||||||
}, [editingRow, dialogueMessageRow])
|
|
||||||
|
|
||||||
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
const updateSessionRows = (nextRows: PostImportRow[]) =>
|
||||||
setSession (current =>
|
setSession (current =>
|
||||||
current != null ? { ...current, rows: nextRows } : current)
|
current != null ? { ...current, rows: nextRows } : current)
|
||||||
@@ -155,7 +126,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const urlChanged = draft.url !== baseRow.url
|
const urlChanged = draft.url !== baseRow.url
|
||||||
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
const nextRow = buildNextEditedRow (baseRow, draft, urlChanged)
|
||||||
|
|
||||||
setSavingRow (editingRow.sourceRow)
|
|
||||||
const nextRows = session.rows.map (row =>
|
const nextRows = session.rows.map (row =>
|
||||||
row.sourceRow === editingRow.sourceRow
|
row.sourceRow === editingRow.sourceRow
|
||||||
? nextRow
|
? nextRow
|
||||||
@@ -176,12 +146,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
const validatedRows = initialisePreviewRows (validated.rows)
|
const validatedRows = initialisePreviewRows (validated.rows)
|
||||||
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)
|
||||||
{
|
|
||||||
setDialogueMessageRow (target)
|
|
||||||
return { saved: false, row: target }
|
return { saved: false, row: target }
|
||||||
}
|
|
||||||
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
updateSessionRows (mergeValidatedImportRows (nextRows, validatedRows))
|
||||||
setDialogueMessageRow (null)
|
|
||||||
setSearchParams ({ })
|
setSearchParams ({ })
|
||||||
return { saved: true, row: null }
|
return { saved: true, row: null }
|
||||||
}
|
}
|
||||||
@@ -192,48 +158,25 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
setSavingRow (null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditingDialogue = async (row: PostImportRow) => {
|
const openEditingDialogue = async (row: PostImportRow) => {
|
||||||
let currentRow = row
|
await dialogue.form ({
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
const initialDraft = buildDraft (currentRow)
|
|
||||||
setDialogueDraft (current => current != null ? current : initialDraft)
|
|
||||||
setDialogueResetRequested (false)
|
|
||||||
|
|
||||||
const action = await dialogue.choice ({
|
|
||||||
title: '投稿を編輯',
|
title: '投稿を編輯',
|
||||||
description: <PostImportRowForm
|
description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
|
||||||
row={currentRow}
|
|
||||||
messageRow={dialogueMessageRow?.sourceRow === currentRow.sourceRow
|
|
||||||
? dialogueMessageRow
|
|
||||||
: null}
|
|
||||||
saving={savingRow === currentRow.sourceRow}
|
|
||||||
onDraftChange={setDialogueDraft}
|
|
||||||
onResetRequestedChange={setDialogueResetRequested}/>,
|
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
choices: [{ value: 'save', label: '編輯内容を保存' }] as const })
|
size: 'large',
|
||||||
|
body: controls => (
|
||||||
if (action !== 'save')
|
<PostImportRowForm
|
||||||
{
|
row={row}
|
||||||
setSearchParams ({ })
|
controls={controls}
|
||||||
return
|
onSave={({ draft, resetRequested }) =>
|
||||||
}
|
saveDraft ({
|
||||||
|
|
||||||
const draft = dialogueDraftRef.current ?? initialDraft
|
|
||||||
const result = await saveDraft ({
|
|
||||||
draft,
|
draft,
|
||||||
resetRequested: dialogueResetRequestedRef.current,
|
resetRequested,
|
||||||
resetSnapshot: currentRow.resetSnapshot })
|
resetSnapshot: row.resetSnapshot })}/>) })
|
||||||
if (result.saved)
|
setSearchParams ({ })
|
||||||
return
|
|
||||||
|
|
||||||
currentRow = result.row ?? currentRow
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -267,7 +210,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
if (firstInvalid != null)
|
if (firstInvalid != null)
|
||||||
{
|
{
|
||||||
setSession ({ ...session, rows: mergedRows })
|
setSession ({ ...session, rows: mergedRows })
|
||||||
setDialogueMessageRow (firstInvalid)
|
|
||||||
setSearchParams ({ edit: String (firstInvalid.sourceRow) })
|
setSearchParams ({ edit: String (firstInvalid.sourceRow) })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -308,7 +250,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
rows: nextRows,
|
rows: nextRows,
|
||||||
repairMode: 'failed' as const }
|
repairMode: 'failed' as const }
|
||||||
setSession (repairSession)
|
setSession (repairSession)
|
||||||
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)
|
||||||
@@ -358,7 +299,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
<MainArea className="min-h-0">
|
<MainArea className="min-h-0">
|
||||||
<div className="mx-auto max-w-6xl space-y-4 p-4">
|
<div className="mx-auto max-w-6xl space-y-4 p-4">
|
||||||
<PageTitle>投稿情報の確認・編輯</PageTitle>
|
<PageTitle>投稿情報の確認</PageTitle>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{reviewRows.map (row => (
|
{reviewRows.map (row => (
|
||||||
@@ -380,7 +321,6 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
skipPlannedCount={counts.skipPlanned}
|
skipPlannedCount={counts.skipPlanned}
|
||||||
onBack={() => navigate ('/posts/import')}
|
onBack={() => navigate ('/posts/import')}
|
||||||
onSubmit={submit}/>
|
onSubmit={submit}/>
|
||||||
|
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +355,7 @@ const PostImportFooter = (
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={loading || processableCount === 0}>
|
disabled={loading || processableCount === 0}>
|
||||||
取込を実行
|
取込実行
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする