このコミットが含まれているのは:
+38
-33
@@ -1,8 +1,9 @@
|
||||
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { BrowserRouter,
|
||||
import { createBrowserRouter,
|
||||
Navigate,
|
||||
Route,
|
||||
RouterProvider,
|
||||
Routes,
|
||||
useLocation } from 'react-router-dom'
|
||||
|
||||
@@ -171,6 +172,41 @@ const App: FC = () => {
|
||||
),
|
||||
[animationMode],
|
||||
)
|
||||
const router = useMemo (
|
||||
() => createBrowserRouter ([{
|
||||
path: '*',
|
||||
element: (
|
||||
<DialogueProvider>
|
||||
<UnsavedChangesGuardProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
<MotionConfig
|
||||
reducedMotion={
|
||||
animationMode === 'normal'
|
||||
? 'never'
|
||||
: animationMode === 'reduced'
|
||||
? 'user'
|
||||
: 'always'
|
||||
}>
|
||||
<LayoutWrapper>
|
||||
<motion.div
|
||||
layout={animationMode === 'off' ? false : 'position'}
|
||||
transition={{ layout: appLayoutTransition }}
|
||||
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<RouteTransitionWrapper
|
||||
animationMode={animationMode}
|
||||
user={user}
|
||||
setUser={setUser}/>
|
||||
</motion.div>
|
||||
</LayoutWrapper>
|
||||
</MotionConfig>
|
||||
|
||||
<Toaster/>
|
||||
</KeyboardShortcutsProvider>
|
||||
</UnsavedChangesGuardProvider>
|
||||
</DialogueProvider>) }]),
|
||||
[LayoutWrapper, animationMode, appLayoutTransition, setUser, user],
|
||||
)
|
||||
|
||||
useEffect (() => {
|
||||
applyClientAppearance ()
|
||||
@@ -256,38 +292,7 @@ const App: FC = () => {
|
||||
<>
|
||||
<RouteBlockerOverlay/>
|
||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||
|
||||
<BrowserRouter>
|
||||
<DialogueProvider>
|
||||
<UnsavedChangesGuardProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
<MotionConfig
|
||||
reducedMotion={
|
||||
animationMode === 'normal'
|
||||
? 'never'
|
||||
: animationMode === 'reduced'
|
||||
? 'user'
|
||||
: 'always'
|
||||
}>
|
||||
<LayoutWrapper>
|
||||
<motion.div
|
||||
layout={animationMode === 'off' ? false : 'position'}
|
||||
transition={{ layout: appLayoutTransition }}
|
||||
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<RouteTransitionWrapper
|
||||
animationMode={animationMode}
|
||||
user={user}
|
||||
setUser={setUser}/>
|
||||
</motion.div>
|
||||
</LayoutWrapper>
|
||||
</MotionConfig>
|
||||
|
||||
<Toaster/>
|
||||
</KeyboardShortcutsProvider>
|
||||
</UnsavedChangesGuardProvider>
|
||||
</DialogueProvider>
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router}/>
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createPath, useNavigate } from 'react-router-dom'
|
||||
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||
import { prefetchForURL } from '@/lib/prefetchers'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
|
||||
@@ -36,7 +35,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
const navigate = useNavigate ()
|
||||
const qc = useQueryClient ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
|
||||
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
||||
const path = useMemo (
|
||||
() => typeof to === 'string' ? to : createPath (to),
|
||||
@@ -45,10 +43,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
const url = useMemo (() => {
|
||||
return (new URL (path, window.location.origin)).toString ()
|
||||
}, [path])
|
||||
const nextPathname = useMemo (
|
||||
() => (new URL (path, window.location.origin)).pathname,
|
||||
[path],
|
||||
)
|
||||
const setOverlay = useOverlayStore (s => s.setActive)
|
||||
|
||||
const doPrefetch = async () => {
|
||||
@@ -93,13 +87,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
|
||||
ev.preventDefault ()
|
||||
|
||||
if (nextPathname !== window.location.pathname)
|
||||
{
|
||||
const confirmed = await confirmDiscardNavigation ()
|
||||
if (!(confirmed))
|
||||
return
|
||||
}
|
||||
|
||||
flushSync (() => {
|
||||
setOverlay (true)
|
||||
})
|
||||
|
||||
@@ -10,8 +10,8 @@ import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession'
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
type Draft = PostImportEditableDraft
|
||||
|
||||
|
||||
@@ -4,13 +4,16 @@ import { Button } from '@/components/ui/button'
|
||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { hasVideoTag } from '@/lib/postImportSession'
|
||||
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession'
|
||||
import {
|
||||
canEditReviewRow,
|
||||
canRetryResultRow,
|
||||
hasVideoTag,
|
||||
} from '@/lib/postImportRows'
|
||||
import { cn, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
type Props = {
|
||||
row: PostImportRow
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||
|
||||
export type PostImportDisplayStatus =
|
||||
'ready'
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from '@/lib/postImportTypes'
|
||||
export * from '@/lib/postImportStorage'
|
||||
export * from '@/lib/postImportSourceValidation'
|
||||
export * from '@/lib/postImportRows'
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
getEffectiveKeyBindings,
|
||||
setClientKeyboardSettings,
|
||||
} from '@/lib/settings'
|
||||
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
||||
|
||||
import type {
|
||||
KeyBinding,
|
||||
@@ -97,7 +96,6 @@ const focusSearchTarget = (): void => {
|
||||
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
|
||||
|
||||
const [keyboardSettings, setKeyboardSettingsState] =
|
||||
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
|
||||
@@ -149,11 +147,8 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
||||
}, [])
|
||||
|
||||
const guardedNavigate = useCallback ((path: string) => {
|
||||
confirmDiscardNavigation ().then (confirmed => {
|
||||
if (confirmed)
|
||||
navigate (path)
|
||||
})
|
||||
}, [confirmDiscardNavigation, navigate])
|
||||
}, [navigate])
|
||||
|
||||
const builtinHandlers = useMemo<ShortcutHandlers> (
|
||||
() => ({
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useBlocker, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
@@ -24,9 +24,8 @@ type UseUnsavedChangesGuardOptions = {
|
||||
type UnsavedChangesGuardContextValue = {
|
||||
hasUnsavedChanges: boolean
|
||||
registerUnsavedChangesSource: (
|
||||
source: UnsavedChangesSource | null,
|
||||
) => void
|
||||
confirmDiscardNavigation: () => Promise<boolean>
|
||||
source: UnsavedChangesSource,
|
||||
) => () => void
|
||||
allowNextNavigation: () => void }
|
||||
|
||||
const UnsavedChangesGuardContext =
|
||||
@@ -36,23 +35,57 @@ const UnsavedChangesGuardContext =
|
||||
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const dialogue = useDialogue ()
|
||||
const location = useLocation ()
|
||||
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
|
||||
const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
|
||||
const bypassNextNavigationRef = useRef (false)
|
||||
const [revision, setRevision] = useState (0)
|
||||
const dialogueOpenRef = useRef (false)
|
||||
|
||||
const registerUnsavedChangesSource = useCallback ((
|
||||
nextSource: UnsavedChangesSource | null,
|
||||
) => {
|
||||
setSource (nextSource)
|
||||
source: UnsavedChangesSource,
|
||||
): (() => void) => {
|
||||
const sourceId = Symbol ('unsaved-changes-source')
|
||||
sourcesRef.current.set (sourceId, source)
|
||||
setRevision (current => current + 1)
|
||||
|
||||
return () => {
|
||||
if (!(sourcesRef.current.delete (sourceId)))
|
||||
return
|
||||
|
||||
setRevision (current => current + 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const allowNextNavigation = useCallback (() => {
|
||||
bypassNextNavigationRef.current = true
|
||||
}, [])
|
||||
|
||||
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
|
||||
if (!(source?.dirty))
|
||||
const sources = useMemo (
|
||||
() => [...sourcesRef.current.values ()],
|
||||
[revision],
|
||||
)
|
||||
const dirtySources = useMemo (
|
||||
() => sources.filter (source => source.dirty),
|
||||
[sources],
|
||||
)
|
||||
const hasUnsavedChanges = dirtySources.length > 0
|
||||
const blocker = useBlocker (
|
||||
useCallback (
|
||||
() => hasUnsavedChanges && !(bypassNextNavigationRef.current),
|
||||
[hasUnsavedChanges],
|
||||
),
|
||||
)
|
||||
|
||||
const discardDirtyChanges = useCallback (async (): Promise<boolean> => {
|
||||
if (!(hasUnsavedChanges))
|
||||
return true
|
||||
|
||||
if (dialogueOpenRef.current)
|
||||
return false
|
||||
|
||||
dialogueOpenRef.current = true
|
||||
|
||||
try
|
||||
{
|
||||
const confirmed = await dialogue.confirm ({
|
||||
title: '変更が破棄してページ移動しますか?',
|
||||
confirmText: '変更を破棄して移動',
|
||||
@@ -61,16 +94,57 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
return false
|
||||
|
||||
bypassNextNavigationRef.current = true
|
||||
|
||||
try
|
||||
{
|
||||
for (const source of dirtySources)
|
||||
await source.discard ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
bypassNextNavigationRef.current = false
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}, [dialogue, source])
|
||||
}
|
||||
finally
|
||||
{
|
||||
dialogueOpenRef.current = false
|
||||
}
|
||||
}, [dialogue, dirtySources, hasUnsavedChanges])
|
||||
|
||||
useEffect (() => {
|
||||
if (blocker.state !== 'blocked')
|
||||
return
|
||||
|
||||
let active = true
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await discardDirtyChanges ()
|
||||
if (!(active))
|
||||
return
|
||||
|
||||
if (confirmed)
|
||||
{
|
||||
blocker.proceed ()
|
||||
return
|
||||
}
|
||||
|
||||
blocker.reset ()
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [blocker, discardDirtyChanges])
|
||||
|
||||
useEffect (() => {
|
||||
bypassNextNavigationRef.current = false
|
||||
}, [location.hash, location.pathname, location.search])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(source?.dirty))
|
||||
if (!(hasUnsavedChanges))
|
||||
return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
@@ -82,18 +156,16 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
|
||||
return () => {
|
||||
window.removeEventListener ('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [source?.dirty])
|
||||
}, [hasUnsavedChanges])
|
||||
|
||||
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
|
||||
hasUnsavedChanges: source?.dirty === true,
|
||||
hasUnsavedChanges,
|
||||
registerUnsavedChangesSource,
|
||||
confirmDiscardNavigation,
|
||||
allowNextNavigation,
|
||||
}), [
|
||||
allowNextNavigation,
|
||||
confirmDiscardNavigation,
|
||||
hasUnsavedChanges,
|
||||
registerUnsavedChangesSource,
|
||||
source?.dirty,
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -115,13 +187,9 @@ export const useUnsavedChangesGuard = (
|
||||
if (options == null)
|
||||
return
|
||||
|
||||
context.registerUnsavedChangesSource ({
|
||||
return context.registerUnsavedChangesSource ({
|
||||
dirty: options.dirty,
|
||||
discard: options.onDiscard ?? (() => undefined) })
|
||||
|
||||
return () => {
|
||||
context.registerUnsavedChangesSource (null)
|
||||
}
|
||||
}, [context, options?.dirty, options?.onDiscard])
|
||||
|
||||
return context
|
||||
|
||||
@@ -1197,13 +1197,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}, [hasUnsavedThemeChanges, savedThemeSlots])
|
||||
|
||||
useEffect (() => {
|
||||
registerUnsavedChangesSource ({
|
||||
return registerUnsavedChangesSource ({
|
||||
dirty: hasPageUnsavedChanges,
|
||||
discard: discardAllDirtyChanges })
|
||||
|
||||
return () => {
|
||||
registerUnsavedChangesSource (null)
|
||||
}
|
||||
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
|
||||
|
||||
useKeyboardShortcuts ({
|
||||
|
||||
新しい課題から参照
ユーザをブロックする