このコミットが含まれているのは:
@@ -7,7 +7,7 @@ import { Dialog,
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
import { DialogueContext, useDialogue } from '@/lib/dialogues/useDialogue'
|
import { DialogueContext } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
import type { AlertOptions,
|
import type { AlertOptions,
|
||||||
@@ -42,20 +42,24 @@ type Props = { children: ReactNode }
|
|||||||
|
|
||||||
|
|
||||||
const DialogueProvider: FC<Props> = ({ children }) => {
|
const DialogueProvider: FC<Props> = ({ children }) => {
|
||||||
const [stack, setStack] = useState<DialogueRequest[]> ([])
|
const [queue, setQueue] = useState<DialogueRequest[]> ([])
|
||||||
const [pendingIds, setPendingIds] = useState<number[]> ([])
|
const [pendingIds, setPendingIds] = useState<number[]> ([])
|
||||||
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
|
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
|
||||||
const formControls = useRef<Record<number, DialogueFormControls>> ({ })
|
const formControls = useRef<Record<number, DialogueFormControls>> ({ })
|
||||||
|
const [nestedConfirm, setNestedConfirm] = useState<{
|
||||||
|
parentId: number
|
||||||
|
options: ConfirmOptions
|
||||||
|
resolve: (value: boolean) => void } | null> (null)
|
||||||
|
|
||||||
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
|
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
|
||||||
const id = nextDialogueId
|
const id = nextDialogueId
|
||||||
++nextDialogueId
|
++nextDialogueId
|
||||||
|
|
||||||
setStack (current => [...current, { ...request, id } as DialogueRequest])
|
setQueue (current => [...current, { ...request, id } as DialogueRequest])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const closeRequest = useCallback ((id: number, result?: unknown) => {
|
const closeRequest = useCallback ((id: number, result?: unknown) => {
|
||||||
setStack (current => {
|
setQueue (current => {
|
||||||
const active = current.find (request => request.id === id)
|
const active = current.find (request => request.id === id)
|
||||||
|
|
||||||
if (active == null)
|
if (active == null)
|
||||||
@@ -96,6 +100,23 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
},
|
},
|
||||||
[])
|
[])
|
||||||
|
|
||||||
|
const openNestedConfirm = useCallback (
|
||||||
|
(parentId: number, options: ConfirmOptions) =>
|
||||||
|
new Promise<boolean> (resolve => {
|
||||||
|
setNestedConfirm ({ parentId, options, resolve })
|
||||||
|
}),
|
||||||
|
[])
|
||||||
|
|
||||||
|
const closeNestedConfirm = useCallback ((result: boolean) => {
|
||||||
|
setNestedConfirm (current => {
|
||||||
|
if (current == null)
|
||||||
|
return current
|
||||||
|
|
||||||
|
current.resolve (result)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
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 })
|
||||||
@@ -131,138 +152,172 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
},
|
},
|
||||||
[closeRequest, pendingIds])
|
[closeRequest, pendingIds])
|
||||||
|
|
||||||
|
const active = queue[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogueContext.Provider value={api}>
|
<DialogueContext.Provider value={api}>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{stack.map ((request, index) => {
|
{active && (
|
||||||
const isTop = index === stack.length - 1
|
<Dialog
|
||||||
const pending = pendingIds.includes (request.id)
|
open
|
||||||
const controls =
|
onOpenChange={open => {
|
||||||
request.kind === 'form'
|
const blocked =
|
||||||
? (formControls.current[request.id] ??= {
|
nestedConfirm?.parentId === active.id
|
||||||
close: () => closeRequest (request.id),
|
|| pendingIds.includes (active.id)
|
||||||
setActions: actions => setRequestActions (request.id, actions) })
|
if (!(open) && !(blocked))
|
||||||
: null
|
closeRequest (active.id, active.kind !== 'confirm' && null)
|
||||||
const requestActions = formActions[request.id] ?? []
|
}}>
|
||||||
|
<DialogContent
|
||||||
return (
|
className={
|
||||||
<Dialog
|
active.kind === 'form'
|
||||||
key={request.id}
|
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
|
||||||
open
|
? 'max-w-3xl'
|
||||||
onOpenChange={open => {
|
: 'max-w-lg' } flex-col overflow-hidden gap-0`
|
||||||
if (!(open) && isTop && !(pending))
|
: 'px-6 pb-6 pt-7'
|
||||||
closeRequest (request.id, request.kind !== 'confirm' && null)
|
}
|
||||||
|
onEscapeKeyDown={event => {
|
||||||
|
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
|
||||||
|
event.preventDefault ()
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={event => {
|
||||||
|
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
|
||||||
|
event.preventDefault ()
|
||||||
}}>
|
}}>
|
||||||
<DialogContent
|
{active.kind === 'form'
|
||||||
className={
|
? (
|
||||||
request.kind === 'form'
|
<>
|
||||||
? `flex max-h-[calc(100dvh-1rem)] ${ request.options.size === 'large'
|
<DialogHeader className="shrink-0 pl-8 pr-0 pt-1 text-left">
|
||||||
? 'max-w-3xl'
|
<DialogTitle>{active.options.title}</DialogTitle>
|
||||||
: '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 && (
|
{active.options.description && (
|
||||||
<DialogDescription asChild>
|
<DialogDescription asChild>
|
||||||
<div>{request.options.description}</div>
|
<div>{active.options.description}</div>
|
||||||
</DialogDescription>)}
|
</DialogDescription>)}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
{controls && request.options.body (controls)}
|
{active.options.body (
|
||||||
</div>
|
formControls.current[active.id] ??= {
|
||||||
|
close: () => closeRequest (active.id),
|
||||||
|
setActions: actions => setRequestActions (active.id, actions),
|
||||||
|
confirm: options => openNestedConfirm (active.id, options) })}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="shrink-0 pt-4">
|
<DialogFooter className="shrink-0 pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => closeRequest (request.id)}
|
onClick={() => closeRequest (active.id)}
|
||||||
disabled={pending}>
|
disabled={pendingIds.includes (active.id)
|
||||||
{request.options.cancelText ?? '取消'}
|
|| nestedConfirm?.parentId === active.id}>
|
||||||
</Button>
|
{active.options.cancelText ?? '取消'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{requestActions.map (action => (
|
{(formActions[active.id] ?? []).map (action => (
|
||||||
|
<Button
|
||||||
|
key={action.label}
|
||||||
|
variant={action.variant === 'danger'
|
||||||
|
? 'destructive'
|
||||||
|
: 'default'}
|
||||||
|
onClick={() => void handleFormAction (active.id, action)}
|
||||||
|
disabled={pendingIds.includes (active.id)
|
||||||
|
|| nestedConfirm?.parentId === active.id
|
||||||
|
|| action.disabled}>
|
||||||
|
{action.label}
|
||||||
|
</Button>))}
|
||||||
|
</DialogFooter>
|
||||||
|
</>)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="pl-8">
|
||||||
|
<DialogTitle>{active.options.title}</DialogTitle>
|
||||||
|
|
||||||
|
{active.options.description && (
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div>{active.options.description}</div>
|
||||||
|
</DialogDescription>)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{active.kind === 'confirm' && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
key={action.label}
|
variant="outline"
|
||||||
variant={action.variant === 'danger'
|
onClick={() => closeRequest (active.id, false)}>
|
||||||
? 'destructive'
|
{active.options.cancelText ?? '取消'}
|
||||||
: 'default'}
|
</Button>
|
||||||
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 && (
|
<Button
|
||||||
<DialogDescription asChild>
|
variant={(active.options.variant === 'danger')
|
||||||
<div>{request.options.description}</div>
|
? 'destructive'
|
||||||
</DialogDescription>)}
|
: 'default'}
|
||||||
</DialogHeader>
|
onClick={() => closeRequest (active.id, true)}>
|
||||||
|
{active.options.confirmText ?? '確定'}
|
||||||
|
</Button>
|
||||||
|
</>)}
|
||||||
|
|
||||||
<DialogFooter>
|
{active.kind === 'alert' && (
|
||||||
{request.kind === 'confirm' && (
|
<Button onClick={() => closeRequest (active.id)}>
|
||||||
<>
|
{active.options.okText ?? '確定'}
|
||||||
<Button
|
</Button>)}
|
||||||
variant="outline"
|
|
||||||
onClick={() => closeRequest (request.id, false)}>
|
|
||||||
{request.options.cancelText ?? '取消'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
{active.kind === 'choice' && (
|
||||||
variant={(request.options.variant === 'danger')
|
<>
|
||||||
? 'destructive'
|
<Button
|
||||||
: 'default'}
|
variant="outline"
|
||||||
onClick={() => closeRequest (request.id, true)}>
|
onClick={() => closeRequest (active.id, null)}>
|
||||||
{request.options.confirmText ?? '確定'}
|
{active.options.cancelText ?? '取消'}
|
||||||
</Button>
|
</Button>
|
||||||
</>)}
|
|
||||||
|
|
||||||
{request.kind === 'alert' && (
|
{active.options.choices.map (choice => (
|
||||||
<Button onClick={() => closeRequest (request.id)}>
|
<Button
|
||||||
{request.options.okText ?? '確定'}
|
key={choice.value}
|
||||||
</Button>)}
|
variant={(choice.variant === 'danger')
|
||||||
|
? 'destructive'
|
||||||
|
: 'default'}
|
||||||
|
onClick={() => closeRequest (active.id, choice.value)}>
|
||||||
|
{choice.label}
|
||||||
|
</Button>))}
|
||||||
|
</>)}
|
||||||
|
</DialogFooter>
|
||||||
|
</>)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>)}
|
||||||
|
|
||||||
{request.kind === 'choice' && (
|
{nestedConfirm && (
|
||||||
<>
|
<Dialog
|
||||||
<Button
|
open
|
||||||
variant="outline"
|
onOpenChange={open => {
|
||||||
onClick={() => closeRequest (request.id, null)}>
|
if (!(open))
|
||||||
{request.options.cancelText ?? '取消'}
|
closeNestedConfirm (false)
|
||||||
</Button>
|
}}>
|
||||||
|
<DialogContent className="px-6 pb-6 pt-7">
|
||||||
|
<DialogHeader className="pl-8">
|
||||||
|
<DialogTitle>{nestedConfirm.options.title}</DialogTitle>
|
||||||
|
|
||||||
{request.options.choices.map (choice => (
|
{nestedConfirm.options.description && (
|
||||||
<Button
|
<DialogDescription asChild>
|
||||||
key={choice.value}
|
<div>{nestedConfirm.options.description}</div>
|
||||||
variant={(choice.variant === 'danger')
|
</DialogDescription>)}
|
||||||
? 'destructive'
|
</DialogHeader>
|
||||||
: 'default'}
|
|
||||||
onClick={() => closeRequest (request.id, choice.value)}>
|
<DialogFooter>
|
||||||
{choice.label}
|
<Button
|
||||||
</Button>))}
|
variant="outline"
|
||||||
</>)}
|
onClick={() => closeNestedConfirm (false)}>
|
||||||
</DialogFooter>
|
{nestedConfirm.options.cancelText ?? '取消'}
|
||||||
</>)}
|
</Button>
|
||||||
</DialogContent>
|
|
||||||
</Dialog>)
|
<Button
|
||||||
})}
|
variant={nestedConfirm.options.variant === 'danger'
|
||||||
|
? 'destructive'
|
||||||
|
: 'default'}
|
||||||
|
onClick={() => closeNestedConfirm (true)}>
|
||||||
|
{nestedConfirm.options.confirmText ?? '確定'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>)}
|
||||||
</DialogueContext.Provider>)
|
</DialogueContext.Provider>)
|
||||||
}
|
}
|
||||||
export { useDialogue }
|
export { useDialogue }
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, 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 { useDialogue } from '@/lib/dialogues/useDialogue'
|
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
@@ -70,7 +69,6 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
controls,
|
controls,
|
||||||
onSave },
|
onSave },
|
||||||
) => {
|
) => {
|
||||||
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 [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
|
||||||
const [saving, setSaving] = useState (false)
|
const [saving, setSaving] = useState (false)
|
||||||
@@ -84,7 +82,9 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
}, [row])
|
}, [row])
|
||||||
|
|
||||||
const displayRow = messageRow ?? row
|
const displayRow = messageRow ?? row
|
||||||
const resetDraft = buildResetDraft (row)
|
const resetDraft = useMemo (
|
||||||
|
() => buildResetDraft (row),
|
||||||
|
[row])
|
||||||
const resetDisabled = saving || sameDraft (draft, resetDraft)
|
const resetDisabled = saving || sameDraft (draft, resetDraft)
|
||||||
|
|
||||||
const update = <Key extends keyof Draft,> (
|
const update = <Key extends keyof Draft,> (
|
||||||
@@ -100,7 +100,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
if (resetDisabled)
|
if (resetDisabled)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
const confirmed = await dialogue.confirm ({
|
const confirmed = await controls.confirm ({
|
||||||
title: '変更をリセットしますか?',
|
title: '変更をリセットしますか?',
|
||||||
description: '現在の URL に対する自動取得直後の内容へ戻します.',
|
description: '現在の URL に対する自動取得直後の内容へ戻します.',
|
||||||
confirmText: 'リセット',
|
confirmText: 'リセット',
|
||||||
@@ -113,7 +113,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
setResetRequested (true)
|
setResetRequested (true)
|
||||||
setMessageRow (null)
|
setMessageRow (null)
|
||||||
return false
|
return false
|
||||||
}, [dialogue, resetDisabled, resetDraft])
|
}, [controls, resetDisabled, resetDraft])
|
||||||
|
|
||||||
const save = useCallback (async (): Promise<boolean> => {
|
const save = useCallback (async (): Promise<boolean> => {
|
||||||
setSaving (true)
|
setSaving (true)
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ type DialogueFormAction = {
|
|||||||
|
|
||||||
type DialogueFormControls = {
|
type DialogueFormControls = {
|
||||||
close: () => void
|
close: () => void
|
||||||
setActions: (actions: DialogueFormAction[]) => void }
|
setActions: (actions: DialogueFormAction[]) => void
|
||||||
|
confirm: (options: ConfirmOptions) => Promise<boolean> }
|
||||||
|
|
||||||
type DialogueFormOptions = { title: string
|
type DialogueFormOptions = { title: string
|
||||||
description?: ReactNode
|
description?: ReactNode
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ import { clearPostImportSourceDraft,
|
|||||||
mergeValidatedImportRows,
|
mergeValidatedImportRows,
|
||||||
resultSummaryCounts,
|
resultSummaryCounts,
|
||||||
retryImportRow,
|
retryImportRow,
|
||||||
savePostImportSession,
|
savePostImportSession } from '@/lib/postImportSession'
|
||||||
type PostImportResultRow,
|
|
||||||
type PostImportRow,
|
|
||||||
type PostImportSession } from '@/lib/postImportSession'
|
|
||||||
import Forbidden from '@/pages/Forbidden'
|
import Forbidden from '@/pages/Forbidden'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { PostImportResultRow,
|
||||||
|
PostImportRow,
|
||||||
|
PostImportSession } from '@/lib/postImportSession'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|||||||
@@ -156,12 +156,19 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
toast ({ title: '行の再検証に失敗しました' })
|
toast ({ title: '行の再検証に失敗しました' })
|
||||||
return { saved: false, row: null }
|
return { saved: false, row: null }
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditingDialogue = async (row: PostImportRow) => {
|
const openEditingDialogue = async (row: PostImportRow) => {
|
||||||
|
const saveRowDraft = (
|
||||||
|
{ draft, resetRequested }: {
|
||||||
|
draft: PostImportRowDraft
|
||||||
|
resetRequested: boolean },
|
||||||
|
) =>
|
||||||
|
saveDraft ({
|
||||||
|
draft,
|
||||||
|
resetRequested,
|
||||||
|
resetSnapshot: row.resetSnapshot })
|
||||||
|
|
||||||
await dialogue.form ({
|
await dialogue.form ({
|
||||||
title: '投稿を編輯',
|
title: '投稿を編輯',
|
||||||
description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
|
description: `投稿 ${ row.sourceRow } の内容を確認し、必要な項目を編輯してください.`,
|
||||||
@@ -171,11 +178,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
<PostImportRowForm
|
<PostImportRowForm
|
||||||
row={row}
|
row={row}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
onSave={({ draft, resetRequested }) =>
|
onSave={saveRowDraft}/>) })
|
||||||
saveDraft ({
|
|
||||||
draft,
|
|
||||||
resetRequested,
|
|
||||||
resetSnapshot: row.resetSnapshot })}/>) })
|
|
||||||
setSearchParams ({ })
|
setSearchParams ({ })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import Forbidden from '@/pages/Forbidden'
|
|||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { PostImportRow } from '@/lib/postImportSeession'
|
import type { PostImportRow } from '@/lib/postImportSession'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする