このコミットが含まれているのは:
2026-07-06 20:46:16 +09:00
コミット c3d2d8e1d1
7個のファイルの変更293行の追加179行の削除
+7 -4
ファイルの表示
@@ -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 && <DevModeWatermark/>}
<BrowserRouter>
<KeyboardShortcutsProvider>
<DialogueProvider>
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
@@ -232,8 +234,9 @@ const App: FC = () => {
</MotionConfig>
<Toaster/>
</DialogueProvider>
</KeyboardShortcutsProvider>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</BrowserRouter>
</>)
}
+20 -4
ファイルの表示
@@ -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<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),
[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<HTMLAnchorElement, Props> (({
ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => {
setOverlay (true)
})
@@ -106,7 +122,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
return (
<a ref={ref}
href={typeof to === 'string' ? to : createPath (to)}
href={path}
onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart}
onClick={handleClick}
+1 -1
ファイルの表示
@@ -158,7 +158,7 @@ const BehaviourSettingsSection: FC<Props> = (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
variant="destructive"
disabled={hasUnsavedChanges === false}
onClick={handleDiscard}>
+1 -1
ファイルの表示
@@ -167,7 +167,7 @@ const KeyboardSettingsSection: FC<Props> = (
</Button>
<Button
type="button"
variant="outline"
variant="destructive"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
+18 -9
ファイルの表示
@@ -25,6 +25,7 @@ import {
getEffectiveKeyBindings,
setClientKeyboardSettings,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type {
KeyBinding,
@@ -96,6 +97,7 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation ()
const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
@@ -146,6 +148,13 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
}
}, [])
const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
'global.openShortcutHelp': () => {
@@ -154,16 +163,16 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
'global.focusSearch': () => {
focusSearchTarget ()
},
'navigation.posts': () => navigate ('/posts'),
'navigation.tags': () => navigate ('/tags'),
'navigation.materials': () => navigate ('/materials'),
'navigation.wiki': () => navigate ('/wiki'),
'settings.account': () => navigate ('/users/settings?tab=account'),
'settings.theme': () => navigate ('/users/settings?tab=theme'),
'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'),
'settings.behavior': () => navigate ('/users/settings?tab=behavior'),
'navigation.posts': () => guardedNavigate ('/posts'),
'navigation.tags': () => guardedNavigate ('/tags'),
'navigation.materials': () => guardedNavigate ('/materials'),
'navigation.wiki': () => guardedNavigate ('/wiki'),
'settings.account': () => guardedNavigate ('/users/settings?tab=account'),
'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'),
'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'),
'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'),
}),
[navigate],
[guardedNavigate],
)
const availableActionIds = useMemo<Set<ShortcutActionId>> (
+69
ファイルの表示
@@ -0,0 +1,69 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, PropsWithChildren } from 'react'
type UnsavedChangesSource = {
dirty: boolean
discard: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean> }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
}, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
if (!(source?.dirty))
return true
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま移動すると、保存していない変更は失われます。',
cancelText: 'このページに残る',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
await source.discard ()
return true
}, [dialogue, source])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true,
registerUnsavedChangesSource,
confirmDiscardNavigation,
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
</UnsavedChangesGuardContext.Provider>)
}
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
const context = useContext (UnsavedChangesGuardContext)
if (context == null)
throw new Error ('UnsavedChangesGuardProvider が必要です.')
return context
}
+177 -160
ファイルの表示
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useBeforeUnload, useBlocker, useLocation, useNavigate } from 'react-router-dom'
import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
@@ -28,7 +28,6 @@ import {
getClientThemeMode,
getDefaultUserThemeSlot,
hasStoredClientThemeSelection,
getUserThemeSlotSelection,
normaliseUserThemeSlots,
resolveDisplayedTheme,
setCachedUserThemeSlots,
@@ -36,6 +35,7 @@ import {
upsertUserThemeSlot,
USER_THEME_SLOT_SELECTIONS,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -99,7 +99,7 @@ type ThemeSectionProps = {
draftThemeMode: ClientThemeMode
draftActiveLightThemeSlotNo: UserThemeSlotNo
draftActiveDarkThemeSlotNo: UserThemeSlotNo
editingThemeSlot: UserThemeSlotSelection
currentDisplayedThemeSlot: UserThemeSlotSelection
selectedTheme: ClientTheme
displayedThemeLabel: string
systemThemeLabel: string
@@ -109,10 +109,9 @@ type ThemeSectionProps = {
onSelectThemeMode: (themeMode: ClientThemeMode) => void
onSelectLightThemeSlotNo: (slotNo: UserThemeSlotNo) => void
onSelectDarkThemeSlotNo: (slotNo: UserThemeSlotNo) => void
onSelectEditingThemeSlot: (selection: UserThemeSlotSelection) => void
onThemeTokenChange: (key: ThemeTokenKey, value: string) => void
onTagColourChange: (category: Category, colour: string) => void
onResetThemeSlot: (selection: UserThemeSlotSelection) => void
onResetThemeSlot: () => void
onThemeSubmit: () => void
onThemeDiscard: () => void }
@@ -124,8 +123,16 @@ type ThemeDraftState = {
themeMode: ClientThemeMode
activeLightThemeSlotNo: UserThemeSlotNo
activeDarkThemeSlotNo: UserThemeSlotNo
themeSlots: UserThemeSlotMap
editingThemeSlot: UserThemeSlotSelection }
themeSlots: UserThemeSlotMap }
type ThemeSegmentedOption<T extends string | number> = {
value: T
label: string }
type ThemeSegmentedControlProps<T extends string | number> = {
value: T
options: ThemeSegmentedOption<T>[]
onChange: (value: T) => void }
const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' },
@@ -138,6 +145,11 @@ const themeModeSelections: ThemeModeItem[] = [
{ id: 'light', label: 'ライト' },
{ id: 'dark', label: 'ダーク' } ]
const themeSlotNoSelections: ThemeSegmentedOption<UserThemeSlotNo>[] = [
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' } ]
const sectionClassName =
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
@@ -181,6 +193,32 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
] },
]
const ThemeSegmentedControl = <T extends string | number,> (
{ value, options, onChange }: ThemeSegmentedControlProps<T>,
) => (
<div
role="radiogroup"
className="flex flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
{options.map (option => (
<button
key={String (option.value)}
type="button"
role="radio"
aria-checked={value === option.value}
className={cn (
'min-w-0 rounded-lg px-3 py-2 text-sm font-medium',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2',
value === option.value
? 'bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/40'
: 'bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => onChange (option.value)}>
{option.label}
</button>))}
</div>)
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<ThemeSectionProps> = (
{ draftThemeMode,
draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
editingThemeSlot,
currentDisplayedThemeSlot,
selectedTheme,
displayedThemeLabel,
systemThemeLabel,
@@ -464,15 +522,12 @@ const ThemeSection: FC<ThemeSectionProps> = (
onSelectThemeMode,
onSelectLightThemeSlotNo,
onSelectDarkThemeSlotNo,
onSelectEditingThemeSlot,
onThemeTokenChange,
onTagColourChange,
onResetThemeSlot,
onThemeSubmit,
onThemeDiscard },
) => {
const currentEditableSelection = editingThemeSlot
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -486,7 +541,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
: {systemThemeLabel}
</p>)}
<p className="text-sm text-muted-foreground">
: {themeLabelBySelection (currentEditableSelection)}
: {themeLabelBySelection (currentDisplayedThemeSlot)}
</p>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
@@ -497,7 +552,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
variant="destructive"
disabled={!(hasUnsavedChanges)}
onClick={onThemeDiscard}>
@@ -506,7 +561,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
type="button"
disabled={!(hasUnsavedChanges)}
onClick={onThemeSubmit}>
{`${ themeLabelBySelection (currentDisplayedThemeSlot) } に保存`}
</Button>
</div>
</div>
@@ -517,76 +572,53 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="space-y-2">
<Label></Label>
<select
className={inputClass (false)}
<ThemeSegmentedControl
value={draftThemeMode}
onChange={ev =>
onSelectThemeMode (ev.target.value as ClientThemeMode)}>
{themeModeSelections.map (selection => (
<option key={selection.id} value={selection.id}>
{selection.label}
</option>))}
</select>
options={themeModeSelections.map (selection => ({
value: selection.id,
label: selection.label }))}
onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
</div>
<div className="space-y-2">
<Label></Label>
<select
className={inputClass (false)}
className={cn (inputClass (false), 'w-full')}
value={draftActiveLightThemeSlotNo}
onChange={ev =>
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSelections
.filter (selection => selection.baseTheme === 'light')
.map (selection => (
<option key={selection.id} value={selection.slotNo}>
{selection.label}
</option>))}
{themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}>
{selection.label}
</option>))}
</select>
</div>
<div className="space-y-2">
<Label></Label>
<select
className={inputClass (false)}
className={cn (inputClass (false), 'w-full')}
value={draftActiveDarkThemeSlotNo}
onChange={ev =>
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSelections
.filter (selection => selection.baseTheme === 'dark')
.map (selection => (
<option key={selection.id} value={selection.slotNo}>
{selection.label}
</option>))}
{themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}>
{selection.label}
</option>))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="theme-slot-select"></Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<select
id="theme-slot-select"
className={cn (inputClass (false), 'w-full min-w-0 sm:w-64')}
value={currentEditableSelection}
onChange={ev =>
onSelectEditingThemeSlot (ev.target.value as UserThemeSlotSelection)}>
{themeSelections.map (selection => (
<option key={selection.id} value={selection.id}>
{selection.label}
</option>))}
</select>
<Button
type="button"
variant="ghost"
onClick={() => onResetThemeSlot (currentEditableSelection)}>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={onResetThemeSlot}>
</Button>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p></p>
<p></p>
</div>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
@@ -672,6 +704,7 @@ const SettingPage: FC<Props> = ({ 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<Props> = ({ user, setUser }) => {
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
const [draftThemeSlots, setDraftThemeSlots] =
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
const [savedEditingThemeSlot, setSavedEditingThemeSlot] =
useState<UserThemeSlotSelection> ('user:light:1')
const [editingThemeSlot, setEditingThemeSlot] =
useState<UserThemeSlotSelection> ('user:light:1')
const [dirtyStates, setDirtyStates] =
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
@@ -724,7 +753,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const selectedTheme = useMemo<ClientTheme> (
() => {
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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ user, setUser }) => {
applyClientAppearance (savedThemeSlots)
}
const discardAllDirtyChanges = async (): Promise<void> => {
const currentDirtyStates = Object.values (dirtyStates)
for (const dirtyState of currentDirtyStates)
{
if (dirtyState?.dirty)
await dirtyState.discard ()
}
setDirtyStates ({ })
}
const discardCurrentTabChanges = async (): Promise<void> => {
const currentDirtyState = dirtyStates[activeTab]
@@ -903,10 +938,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
cancelText = 'このまま編集する',
confirmText = '変更を破棄して切り替える',
): Promise<void> => {
const baseState =
hasUnsavedThemeChanges
? savedThemeDraftState ()
: currentThemeDraftState ()
const baseState = currentThemeDraftState ()
if (hasUnsavedThemeChanges)
{
@@ -916,52 +948,52 @@ const SettingPage: FC<Props> = ({ 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<void> => {
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<void> => {
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<void> => {
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<Props> = ({ 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<Props> = ({ user, setUser }) => {
...draftThemeSlots,
[selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } }
applyDraftThemeState ({ themeSlots: nextThemeSlots, nextEditingThemeSlot: selection })
applyDraftThemeState ({ themeSlots: nextThemeSlots })
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
@@ -1044,21 +1076,13 @@ const SettingPage: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ user, setUser }) => {
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
setSavedThemeSlots (completeThemeSlots)
setDraftThemeSlots (completeThemeSlots)
setSavedEditingThemeSlot (initialEditingThemeSlot)
setEditingThemeSlot (initialEditingThemeSlot)
setCachedUserThemeSlots (completeThemeSlots)
applyThemeAppearanceState ({ themeMode,
activeLightThemeSlotNo,
@@ -1218,6 +1234,16 @@ const SettingPage: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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}