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 ({