このコミットが含まれているのは:
2026-07-15 22:51:01 +09:00
コミット d0914fce3b
6個のファイルの変更199行の追加140行の削除
+108 -53
ファイルの表示
@@ -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,77 +152,77 @@ 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
const pending = pendingIds.includes (request.id)
const controls =
request.kind === 'form'
? (formControls.current[request.id] ??= {
close: () => closeRequest (request.id),
setActions: actions => setRequestActions (request.id, actions) })
: null
const requestActions = formActions[request.id] ?? []
return (
<Dialog <Dialog
key={request.id}
open open
onOpenChange={open => { onOpenChange={open => {
if (!(open) && isTop && !(pending)) const blocked =
closeRequest (request.id, request.kind !== 'confirm' && null) nestedConfirm?.parentId === active.id
|| pendingIds.includes (active.id)
if (!(open) && !(blocked))
closeRequest (active.id, active.kind !== 'confirm' && null)
}}> }}>
<DialogContent <DialogContent
className={ className={
request.kind === 'form' active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ request.options.size === 'large' ? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
? 'max-w-3xl' ? 'max-w-3xl'
: 'max-w-lg' } flex-col overflow-hidden gap-0` : 'max-w-lg' } flex-col overflow-hidden gap-0`
: 'px-6 pb-6 pt-7' : 'px-6 pb-6 pt-7'
} }
onEscapeKeyDown={event => { onEscapeKeyDown={event => {
if (pending) if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
event.preventDefault () event.preventDefault ()
}} }}
onPointerDownOutside={event => { onPointerDownOutside={event => {
if (pending) if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
event.preventDefault () event.preventDefault ()
}}> }}>
{request.kind === 'form' {active.kind === 'form'
? ( ? (
<> <>
<DialogHeader className="shrink-0 pl-8 pr-0 pt-1 text-left"> <DialogHeader className="shrink-0 pl-8 pr-0 pt-1 text-left">
<DialogTitle>{request.options.title}</DialogTitle> <DialogTitle>{active.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 (
formControls.current[active.id] ??= {
close: () => closeRequest (active.id),
setActions: actions => setRequestActions (active.id, actions),
confirm: options => openNestedConfirm (active.id, options) })}
</div> </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}>
{active.options.cancelText ?? '取消'}
</Button> </Button>
{requestActions.map (action => ( {(formActions[active.id] ?? []).map (action => (
<Button <Button
key={action.label} key={action.label}
variant={action.variant === 'danger' variant={action.variant === 'danger'
? 'destructive' ? 'destructive'
: 'default'} : 'default'}
onClick={() => void handleFormAction (request.id, action)} onClick={() => void handleFormAction (active.id, action)}
disabled={pending || action.disabled}> disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id
|| action.disabled}>
{action.label} {action.label}
</Button>))} </Button>))}
</DialogFooter> </DialogFooter>
@@ -209,60 +230,94 @@ const DialogueProvider: FC<Props> = ({ children }) => {
: ( : (
<> <>
<DialogHeader className="pl-8"> <DialogHeader className="pl-8">
<DialogTitle>{request.options.title}</DialogTitle> <DialogTitle>{active.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>
<DialogFooter> <DialogFooter>
{request.kind === 'confirm' && ( {active.kind === 'confirm' && (
<> <>
<Button <Button
variant="outline" variant="outline"
onClick={() => closeRequest (request.id, false)}> onClick={() => closeRequest (active.id, false)}>
{request.options.cancelText ?? '取消'} {active.options.cancelText ?? '取消'}
</Button> </Button>
<Button <Button
variant={(request.options.variant === 'danger') variant={(active.options.variant === 'danger')
? 'destructive' ? 'destructive'
: 'default'} : 'default'}
onClick={() => closeRequest (request.id, true)}> onClick={() => closeRequest (active.id, true)}>
{request.options.confirmText ?? '確定'} {active.options.confirmText ?? '確定'}
</Button> </Button>
</>)} </>)}
{request.kind === 'alert' && ( {active.kind === 'alert' && (
<Button onClick={() => closeRequest (request.id)}> <Button onClick={() => closeRequest (active.id)}>
{request.options.okText ?? '確定'} {active.options.okText ?? '確定'}
</Button>)} </Button>)}
{request.kind === 'choice' && ( {active.kind === 'choice' && (
<> <>
<Button <Button
variant="outline" variant="outline"
onClick={() => closeRequest (request.id, null)}> onClick={() => closeRequest (active.id, null)}>
{request.options.cancelText ?? '取消'} {active.options.cancelText ?? '取消'}
</Button> </Button>
{request.options.choices.map (choice => ( {active.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={() => closeRequest (request.id, choice.value)}> onClick={() => closeRequest (active.id, choice.value)}>
{choice.label} {choice.label}
</Button>))} </Button>))}
</>)} </>)}
</DialogFooter> </DialogFooter>
</>)} </>)}
</DialogContent> </DialogContent>
</Dialog>) </Dialog>)}
})}
{nestedConfirm && (
<Dialog
open
onOpenChange={open => {
if (!(open))
closeNestedConfirm (false)
}}>
<DialogContent className="px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle>{nestedConfirm.options.title}</DialogTitle>
{nestedConfirm.options.description && (
<DialogDescription asChild>
<div>{nestedConfirm.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => closeNestedConfirm (false)}>
{nestedConfirm.options.cancelText ?? '取消'}
</Button>
<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 }
+6 -6
ファイルの表示
@@ -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)
+2 -1
ファイルの表示
@@ -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
+4 -4
ファイルの表示
@@ -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 }
+11 -8
ファイルの表示
@@ -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 ({ })
} }
+1 -1
ファイルの表示
@@ -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 }