diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 78fd0a9..c8d135d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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: (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) }]),
+ [LayoutWrapper, animationMode, appLayoutTransition, setUser, user],
+ )
useEffect (() => {
applyClientAppearance ()
@@ -256,38 +292,7 @@ const App: FC = () => {
<>
{import.meta.env.DEV && }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
>)
}
diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx
index 3651b5b..be533a4 100644
--- a/frontend/src/components/PrefetchLink.tsx
+++ b/frontend/src/components/PrefetchLink.tsx
@@ -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 (({
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 (({
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 (({
ev.preventDefault ()
- if (nextPathname !== window.location.pathname)
- {
- const confirmed = await confirmDiscardNavigation ()
- if (!(confirmed))
- return
- }
-
flushSync (() => {
setOverlay (true)
})
diff --git a/frontend/src/components/posts/import/PostImportRowForm.tsx b/frontend/src/components/posts/import/PostImportRowForm.tsx
index 6aa037c..641fe4a 100644
--- a/frontend/src/components/posts/import/PostImportRowForm.tsx
+++ b/frontend/src/components/posts/import/PostImportRowForm.tsx
@@ -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
diff --git a/frontend/src/components/posts/import/PostImportRowSummary.tsx b/frontend/src/components/posts/import/PostImportRowSummary.tsx
index d2fd4b3..9e1cb76 100644
--- a/frontend/src/components/posts/import/PostImportRowSummary.tsx
+++ b/frontend/src/components/posts/import/PostImportRowSummary.tsx
@@ -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
diff --git a/frontend/src/components/posts/import/postImportRowStatus.ts b/frontend/src/components/posts/import/postImportRowStatus.ts
index 0e7ed4a..03b4eb9 100644
--- a/frontend/src/components/posts/import/postImportRowStatus.ts
+++ b/frontend/src/components/posts/import/postImportRowStatus.ts
@@ -1,4 +1,4 @@
-import type { PostImportRow } from '@/lib/postImportSession'
+import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus =
'ready'
diff --git a/frontend/src/lib/postImportSession.ts b/frontend/src/lib/postImportSession.ts
deleted file mode 100644
index d43c394..0000000
--- a/frontend/src/lib/postImportSession.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export * from '@/lib/postImportTypes'
-export * from '@/lib/postImportStorage'
-export * from '@/lib/postImportSourceValidation'
-export * from '@/lib/postImportRows'
diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx
index cc650bd..d6e955f 100644
--- a/frontend/src/lib/useKeyboardShortcuts.tsx
+++ b/frontend/src/lib/useKeyboardShortcuts.tsx
@@ -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 (() => 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 (path)
+ }, [navigate])
const builtinHandlers = useMemo (
() => ({
diff --git a/frontend/src/lib/useUnsavedChangesGuard.tsx b/frontend/src/lib/useUnsavedChangesGuard.tsx
index b866ad4..156901c 100644
--- a/frontend/src/lib/useUnsavedChangesGuard.tsx
+++ b/frontend/src/lib/useUnsavedChangesGuard.tsx
@@ -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'
@@ -22,12 +22,11 @@ type UseUnsavedChangesGuardOptions = {
onDiscard?: () => void | Promise }
type UnsavedChangesGuardContextValue = {
- hasUnsavedChanges: boolean
- registerUnsavedChangesSource: (
- source: UnsavedChangesSource | null,
- ) => void
- confirmDiscardNavigation: () => Promise
- allowNextNavigation: () => void }
+ hasUnsavedChanges: boolean
+ registerUnsavedChangesSource: (
+ source: UnsavedChangesSource,
+ ) => () => void
+ allowNextNavigation: () => void }
const UnsavedChangesGuardContext =
createContext (null)
@@ -36,41 +35,116 @@ const UnsavedChangesGuardContext =
export const UnsavedChangesGuardProvider: FC = ({ children }) => {
const dialogue = useDialogue ()
const location = useLocation ()
- const [source, setSource] = useState (null)
+ const sourcesRef = useRef (new Map ())
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 => {
- 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 => {
+ if (!(hasUnsavedChanges))
return true
- const confirmed = await dialogue.confirm ({
- title: '変更が破棄してページ移動しますか?',
- confirmText: '変更を破棄して移動',
- variant: 'danger' })
- if (!(confirmed))
+ if (dialogueOpenRef.current)
return false
- bypassNextNavigationRef.current = true
- await source.discard ()
- return true
- }, [dialogue, source])
+ dialogueOpenRef.current = true
+
+ try
+ {
+ const confirmed = await dialogue.confirm ({
+ title: '変更が破棄してページ移動しますか?',
+ confirmText: '変更を破棄して移動',
+ variant: 'danger' })
+ if (!(confirmed))
+ return false
+
+ bypassNextNavigationRef.current = true
+
+ try
+ {
+ for (const source of dirtySources)
+ await source.discard ()
+ }
+ catch
+ {
+ bypassNextNavigationRef.current = false
+ return false
+ }
+
+ return true
+ }
+ 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,23 +156,21 @@ export const UnsavedChangesGuardProvider: FC = ({ children })
return () => {
window.removeEventListener ('beforeunload', handleBeforeUnload)
}
- }, [source?.dirty])
+ }, [hasUnsavedChanges])
const value = useMemo (() => ({
- hasUnsavedChanges: source?.dirty === true,
+ hasUnsavedChanges,
registerUnsavedChangesSource,
- confirmDiscardNavigation,
allowNextNavigation,
}), [
allowNextNavigation,
- confirmDiscardNavigation,
+ hasUnsavedChanges,
registerUnsavedChangesSource,
- source?.dirty,
])
return (
- {children}
+ {children}
)
}
@@ -115,13 +187,9 @@ export const useUnsavedChangesGuard = (
if (options == null)
return
- context.registerUnsavedChangesSource ({
- dirty: options.dirty,
- discard: options.onDiscard ?? (() => undefined) })
-
- return () => {
- context.registerUnsavedChangesSource (null)
- }
+ return context.registerUnsavedChangesSource ({
+ dirty: options.dirty,
+ discard: options.onDiscard ?? (() => undefined) })
}, [context, options?.dirty, options?.onDiscard])
return context
diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx
index 5619a6e..1ece04a 100644
--- a/frontend/src/pages/users/SettingPage.tsx
+++ b/frontend/src/pages/users/SettingPage.tsx
@@ -1197,13 +1197,9 @@ const SettingPage: FC = ({ user, setUser }) => {
}, [hasUnsavedThemeChanges, savedThemeSlots])
useEffect (() => {
- registerUnsavedChangesSource ({
- dirty: hasPageUnsavedChanges,
- discard: discardAllDirtyChanges })
-
- return () => {
- registerUnsavedChangesSource (null)
- }
+ return registerUnsavedChangesSource ({
+ dirty: hasPageUnsavedChanges,
+ discard: discardAllDirtyChanges })
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
useKeyboardShortcuts ({