このコミットが含まれているのは:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
新しい課題から参照
ユーザをブロックする