このコミットが含まれているのは:
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 { 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}/>
</>)
}
-13
ファイルの表示
@@ -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)
})
+1 -1
ファイルの表示
@@ -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
+6 -3
ファイルの表示
@@ -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 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { PostImportRow } from '@/lib/postImportSession'
import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus =
'ready'
-4
ファイルの表示
@@ -1,4 +0,0 @@
export * from '@/lib/postImportTypes'
export * from '@/lib/postImportStorage'
export * from '@/lib/postImportSourceValidation'
export * from '@/lib/postImportRows'
+1 -6
ファイルの表示
@@ -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> (
() => ({
+90 -22
ファイルの表示
@@ -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
+1 -5
ファイルの表示
@@ -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 ({