このコミットが含まれているのは:
2026-07-18 19:26:27 +09:00
コミット eae6c30064
9個のファイルの変更155行の追加105行の削除
+38 -33
ファイルの表示
@@ -1,8 +1,9 @@
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react' import { Fragment, useEffect, useMemo, useState } from 'react'
import { BrowserRouter, import { createBrowserRouter,
Navigate, Navigate,
Route, Route,
RouterProvider,
Routes, Routes,
useLocation } from 'react-router-dom' useLocation } from 'react-router-dom'
@@ -171,6 +172,41 @@ const App: FC = () => {
), ),
[animationMode], [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 (() => { useEffect (() => {
applyClientAppearance () applyClientAppearance ()
@@ -256,38 +292,7 @@ const App: FC = () => {
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>} {import.meta.env.DEV && <DevModeWatermark/>}
<RouterProvider router={router}/>
<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>
</>) </>)
} }
-13
ファイルの表示
@@ -6,7 +6,6 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -36,7 +35,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo ( const path = useMemo (
() => typeof to === 'string' ? to : createPath (to), () => typeof to === 'string' ? to : createPath (to),
@@ -45,10 +43,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const url = useMemo (() => { const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString () return (new URL (path, window.location.origin)).toString ()
}, [path]) }, [path])
const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive) const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => { const doPrefetch = async () => {
@@ -93,13 +87,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault () ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => { flushSync (() => {
setOverlay (true) setOverlay (true)
}) })
+1 -1
ファイルの表示
@@ -10,8 +10,8 @@ import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportSession'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue' import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
type Draft = PostImportEditableDraft type Draft = PostImportEditableDraft
+6 -3
ファイルの表示
@@ -4,13 +4,16 @@ import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview' import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge' import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus' import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { hasVideoTag } from '@/lib/postImportSession' import {
import { canEditReviewRow, canRetryResultRow } from '@/lib/postImportSession' canEditReviewRow,
canRetryResultRow,
hasVideoTag,
} from '@/lib/postImportRows'
import { cn, originalCreatedAtString } from '@/lib/utils' import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportRow } from '@/lib/postImportTypes'
type Props = { type Props = {
row: PostImportRow row: PostImportRow
+1 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { PostImportRow } from '@/lib/postImportSession' import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus = export type PostImportDisplayStatus =
'ready' 'ready'
-4
ファイルの表示
@@ -1,4 +0,0 @@
export * from '@/lib/postImportTypes'
export * from '@/lib/postImportStorage'
export * from '@/lib/postImportSourceValidation'
export * from '@/lib/postImportRows'
+2 -7
ファイルの表示
@@ -25,7 +25,6 @@ import {
getEffectiveKeyBindings, getEffectiveKeyBindings,
setClientKeyboardSettings, setClientKeyboardSettings,
} from '@/lib/settings' } from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type { import type {
KeyBinding, KeyBinding,
@@ -97,7 +96,6 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] = const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ()) useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
@@ -149,11 +147,8 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
}, []) }, [])
const guardedNavigate = useCallback ((path: string) => { const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => { navigate (path)
if (confirmed) }, [navigate])
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
const builtinHandlers = useMemo<ShortcutHandlers> ( const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({ () => ({
+104 -36
ファイルの表示
@@ -7,7 +7,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { useLocation } from 'react-router-dom' import { useBlocker, useLocation } from 'react-router-dom'
import { useDialogue } from '@/lib/dialogues/useDialogue' import { useDialogue } from '@/lib/dialogues/useDialogue'
@@ -22,12 +22,11 @@ type UseUnsavedChangesGuardOptions = {
onDiscard?: () => void | Promise<void> } onDiscard?: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = { type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean hasUnsavedChanges: boolean
registerUnsavedChangesSource: ( registerUnsavedChangesSource: (
source: UnsavedChangesSource | null, source: UnsavedChangesSource,
) => void ) => () => void
confirmDiscardNavigation: () => Promise<boolean> allowNextNavigation: () => void }
allowNextNavigation: () => void }
const UnsavedChangesGuardContext = const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null) createContext<UnsavedChangesGuardContextValue | null> (null)
@@ -36,41 +35,116 @@ const UnsavedChangesGuardContext =
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => { export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const location = useLocation () const location = useLocation ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null) const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
const bypassNextNavigationRef = useRef (false) const bypassNextNavigationRef = useRef (false)
const [revision, setRevision] = useState (0)
const dialogueOpenRef = useRef (false)
const registerUnsavedChangesSource = useCallback (( const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null, source: UnsavedChangesSource,
) => { ): (() => void) => {
setSource (nextSource) 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 (() => { const allowNextNavigation = useCallback (() => {
bypassNextNavigationRef.current = true bypassNextNavigationRef.current = true
}, []) }, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => { const sources = useMemo (
if (!(source?.dirty)) () => [...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 return true
const confirmed = await dialogue.confirm ({ if (dialogueOpenRef.current)
title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false return false
bypassNextNavigationRef.current = true dialogueOpenRef.current = true
await source.discard ()
return true try
}, [dialogue, source]) {
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 (() => { useEffect (() => {
bypassNextNavigationRef.current = false bypassNextNavigationRef.current = false
}, [location.hash, location.pathname, location.search]) }, [location.hash, location.pathname, location.search])
useEffect (() => { useEffect (() => {
if (!(source?.dirty)) if (!(hasUnsavedChanges))
return return
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -82,23 +156,21 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
return () => { return () => {
window.removeEventListener ('beforeunload', handleBeforeUnload) window.removeEventListener ('beforeunload', handleBeforeUnload)
} }
}, [source?.dirty]) }, [hasUnsavedChanges])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({ const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true, hasUnsavedChanges,
registerUnsavedChangesSource, registerUnsavedChangesSource,
confirmDiscardNavigation,
allowNextNavigation, allowNextNavigation,
}), [ }), [
allowNextNavigation, allowNextNavigation,
confirmDiscardNavigation, hasUnsavedChanges,
registerUnsavedChangesSource, registerUnsavedChangesSource,
source?.dirty,
]) ])
return ( return (
<UnsavedChangesGuardContext.Provider value={value}> <UnsavedChangesGuardContext.Provider value={value}>
{children} {children}
</UnsavedChangesGuardContext.Provider>) </UnsavedChangesGuardContext.Provider>)
} }
@@ -115,13 +187,9 @@ export const useUnsavedChangesGuard = (
if (options == null) if (options == null)
return return
context.registerUnsavedChangesSource ({ return context.registerUnsavedChangesSource ({
dirty: options.dirty, dirty: options.dirty,
discard: options.onDiscard ?? (() => undefined) }) discard: options.onDiscard ?? (() => undefined) })
return () => {
context.registerUnsavedChangesSource (null)
}
}, [context, options?.dirty, options?.onDiscard]) }, [context, options?.dirty, options?.onDiscard])
return context return context
+3 -7
ファイルの表示
@@ -1197,13 +1197,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}, [hasUnsavedThemeChanges, savedThemeSlots]) }, [hasUnsavedThemeChanges, savedThemeSlots])
useEffect (() => { useEffect (() => {
registerUnsavedChangesSource ({ return registerUnsavedChangesSource ({
dirty: hasPageUnsavedChanges, dirty: hasPageUnsavedChanges,
discard: discardAllDirtyChanges }) discard: discardAllDirtyChanges })
return () => {
registerUnsavedChangesSource (null)
}
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource]) }, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
useKeyboardShortcuts ({ useKeyboardShortcuts ({