diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa3688b..d95cbcf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { applyClientAppearance, setCachedUserThemeSlots, seedClientThemeMode } from '@/lib/settings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' +import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' @@ -210,8 +211,9 @@ const App: FC = () => { {import.meta.env.DEV && } - - + + + { - - + + + ) } diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index e9b210e..3651b5b 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -6,6 +6,7 @@ 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' @@ -35,11 +36,19 @@ 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), + [to], + ) const url = useMemo (() => { - const path = (typeof to === 'string') ? to : createPath (to) - return (new URL (path, location.origin)).toString () - }, [to]) + 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 () => { @@ -84,6 +93,13 @@ export default forwardRef (({ ev.preventDefault () + if (nextPathname !== window.location.pathname) + { + const confirmed = await confirmDiscardNavigation () + if (!(confirmed)) + return + } + flushSync (() => { setOverlay (true) }) @@ -106,7 +122,7 @@ export default forwardRef (({ return ( = (
))} +
) + const previewTag = ( id: number, name: string, @@ -221,7 +259,6 @@ const parseTab = (search: string): SettingsTab => { const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }` const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }` -const SETTINGS_PATH = '/users/settings' const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties => Object.fromEntries ( @@ -249,6 +286,27 @@ const ensureCompleteThemeSlots = ( { ...themeSlots }, ) +const comparableThemeTokensForSelection = ( + themeSlots: UserThemeSlotMap, + selection: UserThemeSlotSelection, +): ThemeTokens => { + const [_, baseTheme, slotNo] = selection.split (':') + const fallback = + getDefaultUserThemeSlot ( + baseTheme as BuiltinThemeId, + Number (slotNo) as UserThemeSlotNo, + ).tokens + + return { + ...fallback, + ...(themeSlots[selection]?.tokens ?? { }), + tagColours: { + ...fallback.tagColours, + ...(themeSlots[selection]?.tokens.tagColours ?? { }), + }, + } +} + const serialiseThemeDraft = ( themeMode: ClientThemeMode, activeLightThemeSlotNo: UserThemeSlotNo, @@ -261,7 +319,7 @@ const serialiseThemeDraft = ( activeDarkThemeSlotNo, themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({ selection, - tokens: themeSlots[selection]?.tokens, + tokens: comparableThemeTokensForSelection (themeSlots, selection), })), }) @@ -454,7 +512,7 @@ const ThemeSection: FC = ( { draftThemeMode, draftActiveLightThemeSlotNo, draftActiveDarkThemeSlotNo, - editingThemeSlot, + currentDisplayedThemeSlot, selectedTheme, displayedThemeLabel, systemThemeLabel, @@ -464,15 +522,12 @@ const ThemeSection: FC = ( onSelectThemeMode, onSelectLightThemeSlotNo, onSelectDarkThemeSlotNo, - onSelectEditingThemeSlot, onThemeTokenChange, onTagColourChange, onResetThemeSlot, onThemeSubmit, onThemeDiscard }, ) => { - const currentEditableSelection = editingThemeSlot - return (
@@ -486,7 +541,7 @@ const ThemeSection: FC = ( システム設定: {systemThemeLabel}

)}

- 編輯対象: {themeLabelBySelection (currentEditableSelection)} + 保存先: {themeLabelBySelection (currentDisplayedThemeSlot)}

{hasUnsavedChanges && (

@@ -497,7 +552,7 @@ const ThemeSection: FC = (

@@ -517,76 +572,53 @@ const ThemeSection: FC = (
- + options={themeModeSelections.map (selection => ({ + value: selection.id, + label: selection.label }))} + onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
-
- -
- - - -
+
+
-

変更は保存時に反映されます。

+

保存するまで反映は確定しません。

@@ -672,6 +704,7 @@ const SettingPage: FC = ({ user, setUser }) => { const location = useLocation () const navigate = useNavigate () const dialogue = useDialogue () + const { registerUnsavedChangesSource } = useUnsavedChangesGuard () const [inheritVsbl, setInheritVsbl] = useState (false) const [loading, setLoading] = useState (true) @@ -693,10 +726,6 @@ const SettingPage: FC = ({ user, setUser }) => { useState (() => ensureCompleteThemeSlots ({ })) const [draftThemeSlots, setDraftThemeSlots] = useState (() => ensureCompleteThemeSlots ({ })) - const [savedEditingThemeSlot, setSavedEditingThemeSlot] = - useState ('user:light:1') - const [editingThemeSlot, setEditingThemeSlot] = - useState ('user:light:1') const [dirtyStates, setDirtyStates] = useState>> ({ }) const [userCodeVsbl, setUserCodeVsbl] = useState (false) @@ -724,7 +753,9 @@ const SettingPage: FC = ({ user, setUser }) => { const selectedTheme = useMemo ( () => { const currentSelection = - themeSelections.find (selection => selection.id === editingThemeSlot) + themeSelections.find ( + selection => selection.id === displayedThemeSelection.selection, + ) ?? themeSelections[0] const themeSlot = draftThemeSlots[currentSelection.id] @@ -736,25 +767,23 @@ const SettingPage: FC = ({ user, setUser }) => { baseTheme: currentSelection.baseTheme, tokens: themeSlot.tokens } }, - [draftThemeSlots, editingThemeSlot]) + [displayedThemeSelection.selection, draftThemeSlots]) const hasUnsavedThemeChanges = useMemo ( () => serialiseThemeDraft (draftThemeMode, draftActiveLightThemeSlotNo, draftActiveDarkThemeSlotNo, draftThemeSlots) - !== (serialiseThemeDraft (savedThemeMode, - savedActiveLightThemeSlotNo, - savedActiveDarkThemeSlotNo, - savedThemeSlots)), + !== serialiseThemeDraft (savedThemeMode, + savedActiveLightThemeSlotNo, + savedActiveDarkThemeSlotNo, + savedThemeSlots), [draftActiveDarkThemeSlotNo, draftActiveLightThemeSlotNo, draftThemeMode, draftThemeSlots, - editingThemeSlot, savedActiveDarkThemeSlotNo, savedActiveLightThemeSlotNo, - savedEditingThemeSlot, savedThemeMode, savedThemeSlots]) const savedName = user?.name ?? '' @@ -812,18 +841,15 @@ const SettingPage: FC = ({ user, setUser }) => { activeLightThemeSlotNo = draftActiveLightThemeSlotNo, activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo, themeSlots = draftThemeSlots, - nextEditingThemeSlot = editingThemeSlot, }: { themeMode?: ClientThemeMode activeLightThemeSlotNo?: UserThemeSlotNo activeDarkThemeSlotNo?: UserThemeSlotNo - themeSlots?: UserThemeSlotMap - nextEditingThemeSlot?: UserThemeSlotSelection }, + themeSlots?: UserThemeSlotMap }, ) => { setDraftThemeMode (themeMode) setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo) setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo) setDraftThemeSlots (themeSlots) - setEditingThemeSlot (nextEditingThemeSlot) setCachedUserThemeSlots (themeSlots) applyThemeAppearanceState ({ themeMode, @@ -836,26 +862,23 @@ const SettingPage: FC = ({ user, setUser }) => { clearUserValidationErrors () } - const currentThemeDraftState = (): ThemeDraftState => ({ +const currentThemeDraftState = (): ThemeDraftState => ({ themeMode: draftThemeMode, activeLightThemeSlotNo: draftActiveLightThemeSlotNo, activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, - themeSlots: draftThemeSlots, - editingThemeSlot }) + themeSlots: draftThemeSlots }) const savedThemeDraftState = (): ThemeDraftState => ({ themeMode: savedThemeMode, activeLightThemeSlotNo: savedActiveLightThemeSlotNo, activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo, - themeSlots: savedThemeSlots, - editingThemeSlot: savedEditingThemeSlot }) + themeSlots: savedThemeSlots }) const discardThemeChanges = () => { setDraftThemeMode (savedThemeMode) setDraftActiveLightThemeSlotNo (savedActiveLightThemeSlotNo) setDraftActiveDarkThemeSlotNo (savedActiveDarkThemeSlotNo) setDraftThemeSlots (savedThemeSlots) - setEditingThemeSlot (savedEditingThemeSlot) setCachedUserThemeSlots (savedThemeSlots) setClientThemeAppearance ({ themeMode: savedThemeMode, @@ -864,6 +887,18 @@ const SettingPage: FC = ({ user, setUser }) => { applyClientAppearance (savedThemeSlots) } + const discardAllDirtyChanges = async (): Promise => { + const currentDirtyStates = Object.values (dirtyStates) + + for (const dirtyState of currentDirtyStates) + { + if (dirtyState?.dirty) + await dirtyState.discard () + } + + setDirtyStates ({ }) + } + const discardCurrentTabChanges = async (): Promise => { const currentDirtyState = dirtyStates[activeTab] @@ -903,10 +938,7 @@ const SettingPage: FC = ({ user, setUser }) => { cancelText = 'このまま編集する', confirmText = '変更を破棄して切り替える', ): Promise => { - const baseState = - hasUnsavedThemeChanges - ? savedThemeDraftState () - : currentThemeDraftState () + const baseState = currentThemeDraftState () if (hasUnsavedThemeChanges) { @@ -916,52 +948,52 @@ const SettingPage: FC = ({ user, setUser }) => { confirmText) if (!(confirmed)) return + + discardThemeChanges () } - const nextState = buildNextState (baseState) + const nextState = buildNextState ( + hasUnsavedThemeChanges + ? savedThemeDraftState () + : baseState) applyDraftThemeState ({ themeMode: nextState.themeMode, activeLightThemeSlotNo: nextState.activeLightThemeSlotNo, activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo, - themeSlots: nextState.themeSlots, - nextEditingThemeSlot: nextState.editingThemeSlot }) + themeSlots: nextState.themeSlots }) } - const routeBlocker = useBlocker (({ currentLocation, nextLocation }) => - hasPageUnsavedChanges - && currentLocation.pathname === SETTINGS_PATH - && nextLocation.pathname !== currentLocation.pathname) - - useEffect (() => { - if (routeBlocker.state !== 'blocked') + const requestThemeMode = async ( + themeMode: ClientThemeMode, + ): Promise => { + if (themeMode === draftThemeMode) return - let active = true + await requestThemeStateChange (baseState => ({ ...baseState, themeMode })) + } - void (async () => { - const confirmed = await confirmDiscardChanges ( - 'このまま移動すると、保存していない変更は失われます。', - 'このページに残る', - '変更を破棄して移動', - ) - if (!(active)) - return + const requestLightThemeSlotNo = async ( + slotNo: UserThemeSlotNo, + ): Promise => { + if (slotNo === draftActiveLightThemeSlotNo) + return - if (!(confirmed)) - { - routeBlocker.reset () - return - } + await requestThemeStateChange (baseState => ({ + ...baseState, + activeLightThemeSlotNo: slotNo })) + } - await discardCurrentTabChanges () - routeBlocker.proceed () - }) () + const requestDarkThemeSlotNo = async ( + slotNo: UserThemeSlotNo, + ): Promise => { + if (slotNo === draftActiveDarkThemeSlotNo) + return - return () => { - active = false - } - }, [routeBlocker]) + await requestThemeStateChange (baseState => ({ + ...baseState, + activeDarkThemeSlotNo: slotNo })) + } useBeforeUnload (event => { if (!(hasPageUnsavedChanges)) @@ -972,9 +1004,9 @@ const SettingPage: FC = ({ user, setUser }) => { }, { capture: true }) const updateDraftThemeSlot = ( - selection: UserThemeSlotSelection, updater: (tokens: ThemeTokens) => ThemeTokens, ) => { + const selection = displayedThemeSelection.selection const currentThemeSlot = draftThemeSlots[selection] ?? ensureCompleteThemeSlots ({ })[selection] @@ -982,7 +1014,7 @@ const SettingPage: FC = ({ user, setUser }) => { ...draftThemeSlots, [selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } } - applyDraftThemeState ({ themeSlots: nextThemeSlots, nextEditingThemeSlot: selection }) + applyDraftThemeState ({ themeSlots: nextThemeSlots }) } const handleTabKeyDown = (event: KeyboardEvent) => { @@ -1044,21 +1076,13 @@ const SettingPage: FC = ({ user, setUser }) => { try { + const selection = displayedThemeSelection.selection let nextSavedThemeSlots = { ...savedThemeSlots } + const draftThemeSlot = draftThemeSlots[selection] + const savedThemeSlot = savedThemeSlots[selection] - for (const selection of USER_THEME_SLOT_SELECTIONS) + if (JSON.stringify (draftThemeSlot?.tokens) !== JSON.stringify (savedThemeSlot?.tokens)) { - const draftThemeSlot = draftThemeSlots[selection] - const savedThemeSlot = savedThemeSlots[selection] - const requiresPersist = - selection === editingThemeSlot - && savedThemeSlot?.createdAt == null - - if ((JSON.stringify (draftThemeSlot?.tokens) - === JSON.stringify (savedThemeSlot?.tokens)) - && !(requiresPersist)) - continue - const persisted = await upsertUserThemeSlot ( draftThemeSlot?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId, draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo, @@ -1075,8 +1099,6 @@ const SettingPage: FC = ({ user, setUser }) => { setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo) setSavedThemeSlots (completeThemeSlots) setDraftThemeSlots (completeThemeSlots) - setSavedEditingThemeSlot (editingThemeSlot) - setEditingThemeSlot (editingThemeSlot) // Display mode and active slot numbers are browser-local state. setClientThemeAppearance ({ themeMode: draftThemeMode, @@ -1128,10 +1150,6 @@ const SettingPage: FC = ({ user, setUser }) => { : settings.theme const activeLightThemeSlotNo = getClientActiveLightThemeSlotNo () const activeDarkThemeSlotNo = getClientActiveDarkThemeSlotNo () - const initialEditingThemeSlot = resolveDisplayedTheme ({ - themeMode, - activeLightThemeSlotNo, - activeDarkThemeSlotNo }, completeThemeSlots).selection setSavedThemeMode (themeMode) setDraftThemeMode (themeMode) @@ -1141,8 +1159,6 @@ const SettingPage: FC = ({ user, setUser }) => { setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo) setSavedThemeSlots (completeThemeSlots) setDraftThemeSlots (completeThemeSlots) - setSavedEditingThemeSlot (initialEditingThemeSlot) - setEditingThemeSlot (initialEditingThemeSlot) setCachedUserThemeSlots (completeThemeSlots) applyThemeAppearanceState ({ themeMode, activeLightThemeSlotNo, @@ -1218,6 +1234,16 @@ const SettingPage: FC = ({ user, setUser }) => { discard: handleThemeDiscard }) }, [hasUnsavedThemeChanges, savedThemeSlots]) + useEffect (() => { + registerUnsavedChangesSource ({ + dirty: hasPageUnsavedChanges, + discard: discardAllDirtyChanges }) + + return () => { + registerUnsavedChangesSource (null) + } + }, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource]) + useKeyboardShortcuts ({ ['settings.account']: () => { requestActiveTab ('account') @@ -1236,7 +1262,7 @@ const SettingPage: FC = ({ user, setUser }) => { draftThemeMode, draftActiveLightThemeSlotNo, draftActiveDarkThemeSlotNo, - editingThemeSlot, + currentDisplayedThemeSlot: displayedThemeSelection.selection, selectedTheme, displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection), systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト', @@ -1244,36 +1270,27 @@ const SettingPage: FC = ({ user, setUser }) => { settingsBaseErrors, settingsFieldErrors, onSelectThemeMode: themeMode => { - requestThemeStateChange (baseState => ({ ...baseState, themeMode })) + requestThemeMode (themeMode) }, onSelectLightThemeSlotNo: slotNo => { - requestThemeStateChange (baseState => ({ - ...baseState, - activeLightThemeSlotNo: slotNo, - editingThemeSlot: getUserThemeSlotSelection ('light', slotNo) })) + requestLightThemeSlotNo (slotNo) }, onSelectDarkThemeSlotNo: slotNo => { - requestThemeStateChange (baseState => ({ - ...baseState, - activeDarkThemeSlotNo: slotNo, - editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo) })) - }, - onSelectEditingThemeSlot: selection => { - requestThemeStateChange (baseState => ({ ...baseState, editingThemeSlot: selection })) + requestDarkThemeSlotNo (slotNo) }, onThemeTokenChange: (key, value) => { - updateDraftThemeSlot (editingThemeSlot, tokens => ({ ...tokens, [key]: value })) + updateDraftThemeSlot (tokens => ({ ...tokens, [key]: value })) }, onTagColourChange: (category, colour) => { - updateDraftThemeSlot (editingThemeSlot, tokens => ({ + updateDraftThemeSlot (tokens => ({ ...tokens, tagColours: { ...tokens.tagColours, [category]: colour } })) }, - onResetThemeSlot: selection => { + onResetThemeSlot: () => { + const selection = displayedThemeSelection.selection const baseTheme = draftThemeSlots[selection]?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId updateDraftThemeSlot ( - selection, () => getDefaultUserThemeSlot ( baseTheme, (draftThemeSlots[selection]?.slotNo @@ -1313,7 +1330,7 @@ const SettingPage: FC = ({ user, setUser }) => { : ['border-border bg-background text-foreground']), )} onClick={() => { - void requestActiveTab (tab.id) + requestActiveTab (tab.id) }}> {tab.label} {settingsTabErrors[tab.id] ? ' !' : ''} @@ -1367,7 +1384,7 @@ const SettingPage: FC = ({ user, setUser }) => { 'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']), )} onClick={() => { - void requestActiveTab (tab.id) + requestActiveTab (tab.id) }} onKeyDown={handleTabKeyDown}> {tab.label}