このコミットが含まれているのは:
2026-07-15 22:41:06 +09:00
コミット 0ac7332458
4個のファイルの変更331行の追加232行の削除
+186 -105
ファイルの表示
@@ -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 { Dialog,
@@ -7,29 +7,16 @@ import { Dialog,
DialogFooter,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { DialogueContext, useDialogue } from '@/lib/dialogues/useDialogue'
import type { FC, 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 }
import type { AlertOptions,
ChoiceOptions,
ConfirmOptions,
DialogueAPI,
DialogueFormAction,
DialogueFormControls,
DialogueFormOptions } from '@/lib/dialogues/useDialogue'
type DialogueRequest =
| { id: number
@@ -44,13 +31,10 @@ type DialogueRequest =
kind: 'choice'
options: ChoiceOptions<string>
resolve: (value: string | null) => void }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
alert: (options: AlertOptions) => Promise<void>
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }
const DialogueContext = createContext<DialogueAPI | null> (null)
| { id: number
kind: 'form'
options: DialogueFormOptions
resolve: () => void }
let nextDialogueId = 1
@@ -58,21 +42,24 @@ type Props = { children: ReactNode }
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 id = nextDialogueId
++nextDialogueId
setQueue (q => [...q, { ...request, id } as DialogueRequest])
setStack (current => [...current, { ...request, id } as DialogueRequest])
}, [])
const closeActive = useCallback ((result?: unknown) => {
setQueue (q => {
const [active, ...rest] = q
const closeRequest = useCallback ((id: number, result?: unknown) => {
setStack (current => {
const active = current.find (request => request.id === id)
if (!(active))
return rest
if (active == null)
return current
switch (active.kind)
{
@@ -87,12 +74,28 @@ const DialogueProvider: FC<Props> = ({ children }) => {
case 'choice':
active.resolve ((result ?? null) as string | null)
break
case 'form':
active.resolve ()
break
}
return rest
return current.filter (request => request.id !== id)
})
setPendingIds (current => current.filter (_1 => _1 !== id))
setFormActions (current => {
const { [id]: _, ...rest } = current
return rest
})
delete formControls.current[id]
}, [])
const setRequestActions = useCallback (
(id: number, actions: DialogueFormAction[]) => {
setFormActions (current => ({ ...current, [id]: actions }))
},
[])
const api = useMemo<DialogueAPI> (() => ({
confirm: options => new Promise<boolean> (resolve => {
push ({ kind: 'confirm', options, resolve })
@@ -103,86 +106,164 @@ const DialogueProvider: FC<Props> = ({ children }) => {
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
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 (
<DialogueContext.Provider value={api}>
{children}
<Dialog
open={Boolean (active)}
onOpenChange={open => {
if (!(open))
closeActive (active?.kind !== 'confirm' && null)
}}>
{active && (
<DialogContent className="px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle>
{stack.map ((request, index) => {
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] ?? []
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
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>
<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeActive (false)}>
{active.options.cancelText ?? '取消'}
</Button>
{request.options.description && (
<DialogDescription asChild>
<div>{request.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{controls && request.options.body (controls)}
</div>
{active.kind === 'alert' && (
<Button onClick={() => closeActive ()}>
{active.options.okText ?? '確定'}
</Button>)}
<DialogFooter className="shrink-0 pt-4">
<Button
variant="outline"
onClick={() => closeRequest (request.id)}
disabled={pending}>
{request.options.cancelText ?? '取消'}
</Button>
{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeActive (null)}>
{active.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>
{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</DialogContent>)}
</Dialog>
{request.options.description && (
<DialogDescription asChild>
<div>{request.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<DialogFooter>
{request.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeRequest (request.id, false)}>
{request.options.cancelText ?? '取消'}
</Button>
<Button
variant={(request.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeRequest (request.id, true)}>
{request.options.confirmText ?? '確定'}
</Button>
</>)}
{request.kind === 'alert' && (
<Button onClick={() => closeRequest (request.id)}>
{request.options.okText ?? '確定'}
</Button>)}
{request.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeRequest (request.id, null)}>
{request.options.cancelText ?? '取消'}
</Button>
{request.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeRequest (request.id, choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</>)}
</DialogContent>
</Dialog>)
})}
</DialogueContext.Provider>)
}
export const useDialogue = () => {
const dialogue = useContext (DialogueContext)
if (!(dialogue))
throw new Error ('useDialogue must be used inside DialogueProvider')
return dialogue
}
export { useDialogue }
export default DialogueProvider
+55 -42
ファイルの表示
@@ -1,17 +1,17 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, 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 { Dispatch, FC, SetStateAction } from 'react'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
type Draft = {
url: string
@@ -24,11 +24,12 @@ type Draft = {
parentPostIds: string }
type Props = {
row: PostImportRow
messageRow?: PostImportRow | null
saving: boolean
onDraftChange: Dispatch<SetStateAction<Draft | null>>
onResetRequestedChange: Dispatch<SetStateAction<boolean>> }
row: PostImportRow
controls: DialogueFormControls
onSave: (args: { draft: Draft
resetRequested: boolean }) => Promise<{
saved: boolean
row: PostImportRow | null }> }
const buildDraft = (row: PostImportRow): Draft => ({
url: row.url,
@@ -66,22 +67,21 @@ const sameDraft = (left: Draft, right: Draft): boolean =>
const PostImportRowForm: FC<Props> = (
{ row,
messageRow,
saving,
onDraftChange,
onResetRequestedChange },
controls,
onSave },
) => {
const dialogue = useDialogue ()
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
const [saving, setSaving] = useState (false)
const [resetRequested, setResetRequested] = useState (false)
useEffect (() => {
const nextDraft = buildDraft (row)
setDraft (nextDraft)
setMessageRow (null)
setResetRequested (false)
onDraftChange (nextDraft)
onResetRequestedChange (false)
}, [onDraftChange, onResetRequestedChange, row])
}, [row])
const displayRow = messageRow ?? row
const resetDraft = buildResetDraft (row)
@@ -91,16 +91,14 @@ const PostImportRowForm: FC<Props> = (
key: Key,
value: Draft[Key],
) => {
setDraft (current => {
const next = { ...current, [key]: value }
onDraftChange (next)
return next
})
if (messageRow != null)
setMessageRow (null)
setDraft (current => ({ ...current, [key]: value }))
}
const reset = async () => {
const reset = useCallback (async (): Promise<boolean> => {
if (resetDisabled)
return
return false
const confirmed = await dialogue.confirm ({
title: '変更をリセットしますか?',
@@ -109,22 +107,48 @@ const PostImportRowForm: FC<Props> = (
cancelText: '取消',
variant: 'danger' })
if (!(confirmed))
return
return false
setDraft (resetDraft)
setResetRequested (true)
onDraftChange (resetDraft)
onResetRequestedChange (true)
}
setMessageRow (null)
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 (
<>
<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">
<p className="text-sm text-muted-foreground">
稿 {row.sourceRow}
</p>
<div className="grid gap-6 md:grid-cols-[7rem_minmax(0,1fr)]">
<div className="space-y-3">
<ThumbnailPreview
@@ -196,16 +220,6 @@ const PostImportRowForm: FC<Props> = (
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
<div className="pt-2">
<Button
type="button"
variant="destructive"
onClick={() => reset ()}
disabled={resetDisabled}>
</Button>
</div>
</div>
</div>
</div>
@@ -259,4 +273,3 @@ const PostImportAreaField = (
export default PostImportRowForm
export { buildDraft }
export type { Draft as PostImportRowDraft }
export type { Draft as PostImportRowDraft }