このコミットが含まれているのは:
2026-07-06 19:34:11 +09:00
コミット f4dea9b91e
6個のファイルの変更290行の追加305行の削除
+173 -178
ファイルの表示
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import { useBeforeUnload, useBlocker, useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
@@ -21,7 +21,6 @@ import { apiPut } from '@/lib/api'
import {
applyClientAppearance,
applyThemeAppearanceState,
DEFAULT_USER_SETTINGS,
fetchUserSettings,
fetchUserThemeSlots,
getClientActiveDarkThemeSlotNo,
@@ -34,7 +33,6 @@ import {
resolveDisplayedTheme,
setCachedUserThemeSlots,
setClientThemeAppearance,
updateUserSettings,
upsertUserThemeSlot,
USER_THEME_SLOT_SELECTIONS,
} from '@/lib/settings'
@@ -51,7 +49,6 @@ import type {
ClientTheme,
ThemeTokens,
ThemeTagColours,
UserSettings,
UserThemeSlotMap,
UserThemeSlotNo,
UserThemeSlotSelection,
@@ -224,6 +221,7 @@ 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 (
@@ -679,7 +677,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const [loading, setLoading] = useState (true)
const [name, setName] = useState ('')
const [settingsError, setSettingsError] = useState<string | null> (null)
const [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
const [savedThemeMode, setSavedThemeMode] =
useState<ClientThemeMode> (getClientThemeMode ())
const [draftThemeMode, setDraftThemeMode] =
@@ -719,45 +716,37 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
() => resolveDisplayedTheme ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
}, draftThemeSlots),
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
[draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots],
)
draftThemeSlots])
const selectedTheme = useMemo<ClientTheme> (
() => {
const currentSelection =
themeSelections.find (selection => selection.id === editingThemeSlot)
?? themeSelections[0]
const themeSlot =
draftThemeSlots[currentSelection.id]
?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo)
return {
id: currentSelection.baseTheme,
name: themeLabelBySelection (currentSelection.id),
builtin: false,
baseTheme: currentSelection.baseTheme,
tokens: themeSlot.tokens,
}
const currentSelection =
themeSelections.find (selection => selection.id === editingThemeSlot)
?? themeSelections[0]
const themeSlot =
draftThemeSlots[currentSelection.id]
?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo)
return {
id: currentSelection.baseTheme,
name: themeLabelBySelection (currentSelection.id),
builtin: false,
baseTheme: currentSelection.baseTheme,
tokens: themeSlot.tokens }
},
[draftThemeSlots, editingThemeSlot],
)
[draftThemeSlots, editingThemeSlot])
const hasUnsavedThemeChanges = useMemo (
() =>
serialiseThemeDraft (
draftThemeMode,
draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
draftThemeSlots,
)
!== serialiseThemeDraft (
savedThemeMode,
savedActiveLightThemeSlotNo,
savedActiveDarkThemeSlotNo,
savedThemeSlots,
),
serialiseThemeDraft (draftThemeMode,
draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
draftThemeSlots)
!== (serialiseThemeDraft (savedThemeMode,
savedActiveLightThemeSlotNo,
savedActiveDarkThemeSlotNo,
savedThemeSlots)),
[draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
@@ -767,8 +756,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
savedActiveLightThemeSlotNo,
savedEditingThemeSlot,
savedThemeMode,
savedThemeSlots],
)
savedThemeSlots])
const savedName = user?.name ?? ''
const hasUnsavedAccountChanges = name !== savedName
const hasPageUnsavedChanges = Object.values (dirtyStates).some (state => state.dirty)
const applyActiveTab = (tab: SettingsTab) => {
const params = new URLSearchParams (location.search)
@@ -783,16 +774,16 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
state: SettingsTabDirtyState,
) => {
setDirtyStates (current => {
const next = { ...current }
const next = { ...current }
if (!(state.dirty))
{
delete next[tab]
return next
}
if (!(state.dirty))
{
delete next[tab]
return next
}
next[tab] = state
return next
next[tab] = state
return next
})
}
@@ -806,8 +797,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
description,
confirmText,
cancelText,
variant: 'danger',
})
variant: 'danger' })
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
() => (
@@ -815,36 +805,35 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
behavior: false,
theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: conflictActionIds.size > 0 }),
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name],
)
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name])
const applyDraftThemeState = (
{ themeMode = draftThemeMode,
activeLightThemeSlotNo = draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo,
themeSlots = draftThemeSlots,
nextEditingThemeSlot = editingThemeSlot }: {
themeMode?: ClientThemeMode
activeLightThemeSlotNo?: UserThemeSlotNo
activeDarkThemeSlotNo?: UserThemeSlotNo
themeSlots?: UserThemeSlotMap
nextEditingThemeSlot?: UserThemeSlotSelection },
nextEditingThemeSlot = editingThemeSlot,
}: { themeMode?: ClientThemeMode
activeLightThemeSlotNo?: UserThemeSlotNo
activeDarkThemeSlotNo?: UserThemeSlotNo
themeSlots?: UserThemeSlotMap
nextEditingThemeSlot?: UserThemeSlotSelection },
) => {
setDraftThemeMode (themeMode)
setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
setDraftThemeSlots (themeSlots)
setEditingThemeSlot (nextEditingThemeSlot)
setDraftSettings (current => ({
...current,
theme: themeMode,
}))
setCachedUserThemeSlots (themeSlots)
applyThemeAppearanceState ({
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
}, themeSlots)
activeDarkThemeSlotNo }, themeSlots)
}
const discardAccountChanges = () => {
setName (savedName)
clearUserValidationErrors ()
}
const currentThemeDraftState = (): ThemeDraftState => ({
@@ -852,16 +841,14 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
themeSlots: draftThemeSlots,
editingThemeSlot,
})
editingThemeSlot })
const savedThemeDraftState = (): ThemeDraftState => ({
themeMode: savedThemeMode,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
themeSlots: savedThemeSlots,
editingThemeSlot: savedEditingThemeSlot,
})
editingThemeSlot: savedEditingThemeSlot })
const discardThemeChanges = () => {
setDraftThemeMode (savedThemeMode)
@@ -873,11 +860,19 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setClientThemeAppearance ({
themeMode: savedThemeMode,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
})
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo })
applyClientAppearance (savedThemeSlots)
}
const discardCurrentTabChanges = async (): Promise<void> => {
const currentDirtyState = dirtyStates[activeTab]
if (currentDirtyState?.dirty)
await currentDirtyState.discard ()
setDirtyStates ({ })
}
const requestActiveTab = async (
nextTab: SettingsTab,
): Promise<void> => {
@@ -894,12 +889,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const confirmed = await confirmDiscardChanges (
'このタブを離れると、保存していない変更は失われます。',
'このタブに残る',
'変更を破棄して移動',
)
'変更を破棄して移動')
if (!(confirmed))
return
await currentDirtyState.discard ()
await discardCurrentTabChanges ()
applyActiveTab (nextTab)
}
@@ -919,8 +913,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const confirmed = await confirmDiscardChanges (
description,
cancelText,
confirmText,
)
confirmText)
if (!(confirmed))
return
}
@@ -932,10 +925,52 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
themeSlots: nextState.themeSlots,
nextEditingThemeSlot: nextState.editingThemeSlot,
})
nextEditingThemeSlot: nextState.editingThemeSlot })
}
const routeBlocker = useBlocker (({ currentLocation, nextLocation }) =>
hasPageUnsavedChanges
&& currentLocation.pathname === SETTINGS_PATH
&& nextLocation.pathname !== currentLocation.pathname)
useEffect (() => {
if (routeBlocker.state !== 'blocked')
return
let active = true
void (async () => {
const confirmed = await confirmDiscardChanges (
'このまま移動すると、保存していない変更は失われます。',
'このページに残る',
'変更を破棄して移動',
)
if (!(active))
return
if (!(confirmed))
{
routeBlocker.reset ()
return
}
await discardCurrentTabChanges ()
routeBlocker.proceed ()
}) ()
return () => {
active = false
}
}, [routeBlocker])
useBeforeUnload (event => {
if (!(hasPageUnsavedChanges))
return
event.preventDefault ()
event.returnValue = ''
}, { capture: true })
const updateDraftThemeSlot = (
selection: UserThemeSlotSelection,
updater: (tokens: ThemeTokens) => ThemeTokens,
@@ -945,16 +980,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
?? ensureCompleteThemeSlots ({ })[selection]
const nextThemeSlots = {
...draftThemeSlots,
[selection]: {
...currentThemeSlot,
tokens: updater (currentThemeSlot.tokens),
},
}
[selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } }
applyDraftThemeState ({
themeSlots: nextThemeSlots,
nextEditingThemeSlot: selection,
})
applyDraftThemeState ({ themeSlots: nextThemeSlots, nextEditingThemeSlot: selection })
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
@@ -965,23 +993,23 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault ()
void requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
break
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault ()
void requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
break
case 'Home':
event.preventDefault ()
void requestActiveTab (tabs[0].id)
requestActiveTab (tabs[0].id)
break
case 'End':
event.preventDefault ()
void requestActiveTab (tabs[tabs.length - 1].id)
requestActiveTab (tabs[tabs.length - 1].id)
break
}
}
@@ -999,9 +1027,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{
const data = await apiPut<User> (
`/users/${ user.id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } },
)
{ headers: { 'Content-Type': 'multipart/form-data' } })
setUser (currentUser => ({ ...currentUser, ...data }))
setName (data.name ?? '')
toast ({ title: '表示名を更新しました.' })
}
catch (submitError)
@@ -1026,11 +1054,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
selection === editingThemeSlot
&& savedThemeSlot?.createdAt == null
if (
JSON.stringify (draftThemeSlot?.tokens)
=== JSON.stringify (savedThemeSlot?.tokens)
&& !(requiresPersist)
)
if ((JSON.stringify (draftThemeSlot?.tokens)
=== JSON.stringify (savedThemeSlot?.tokens))
&& !(requiresPersist))
continue
const persisted = await upsertUserThemeSlot (
@@ -1038,19 +1064,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo,
draftThemeSlot?.tokens ?? getDefaultUserThemeSlot (
selection.split (':')[1] as BuiltinThemeId,
Number (selection.split (':')[2]) as UserThemeSlotNo,
).tokens,
)
nextSavedThemeSlots = {
...nextSavedThemeSlots,
[selection]: persisted,
}
Number (selection.split (':')[2]) as UserThemeSlotNo).tokens)
nextSavedThemeSlots = { ...nextSavedThemeSlots, [selection]: persisted }
}
const data = await updateUserSettings ({ theme: draftThemeMode })
const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)
setDraftSettings ({ ...data, theme: draftThemeMode })
setSavedThemeMode (draftThemeMode)
setSavedActiveLightThemeSlotNo (draftActiveLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
@@ -1058,11 +1077,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setDraftThemeSlots (completeThemeSlots)
setSavedEditingThemeSlot (editingThemeSlot)
setEditingThemeSlot (editingThemeSlot)
// Display mode and active slot numbers are browser-local state.
setClientThemeAppearance ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
})
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo })
setCachedUserThemeSlots (completeThemeSlots)
applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' })
@@ -1096,10 +1115,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
try
{
const [settings, themeSlots] = await Promise.all ([
fetchUserSettings (),
fetchUserThemeSlots (),
])
const [settings, themeSlots] = await Promise.all ([fetchUserSettings (),
fetchUserThemeSlots ()])
if (cancelled)
return
@@ -1114,10 +1131,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const initialEditingThemeSlot = resolveDisplayedTheme ({
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
}, completeThemeSlots).selection
activeDarkThemeSlotNo }, completeThemeSlots).selection
setDraftSettings ({ ...settings, theme: themeMode })
setSavedThemeMode (themeMode)
setDraftThemeMode (themeMode)
setSavedActiveLightThemeSlotNo (activeLightThemeSlotNo)
@@ -1129,11 +1144,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setSavedEditingThemeSlot (initialEditingThemeSlot)
setEditingThemeSlot (initialEditingThemeSlot)
setCachedUserThemeSlots (completeThemeSlots)
applyThemeAppearanceState ({
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
}, completeThemeSlots)
applyThemeAppearanceState ({ themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }, completeThemeSlots)
setSettingsError (null)
}
catch
@@ -1160,6 +1173,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
applyActiveTab ('account')
}, [location.pathname, location.search, userFieldErrors.name])
useEffect (() => {
updateTabDirtyState ('account', {
dirty: hasUnsavedAccountChanges,
discard: discardAccountChanges })
}, [hasUnsavedAccountChanges, savedName])
useEffect (() => {
if (settingsFieldErrors.theme?.length)
applyActiveTab ('theme')
@@ -1169,8 +1188,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
applyThemeAppearanceState ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
}, draftThemeSlots)
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots)
return () => {
setCachedUserThemeSlots (savedThemeSlots)
@@ -1184,39 +1202,35 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const accountSectionProps: AccountSectionProps | null =
user
? {
user,
? { user,
name,
onNameChange: setName,
onInheritOpen: () => setInheritVsbl (true),
onUserCodeOpen: () => setUserCodeVsbl (true),
onUserSubmit: handleUserSubmit,
userBaseErrors,
userFieldErrors,
}
userFieldErrors }
: null
useEffect (() => {
updateTabDirtyState ('theme', {
dirty: hasUnsavedThemeChanges,
discard: handleThemeDiscard,
})
discard: handleThemeDiscard })
}, [hasUnsavedThemeChanges, savedThemeSlots])
useKeyboardShortcuts ({
'settings.account': () => {
void requestActiveTab ('account')
['settings.account']: () => {
requestActiveTab ('account')
},
'settings.behavior': () => {
void requestActiveTab ('behavior')
['settings.behavior']: () => {
requestActiveTab ('behavior')
},
'settings.theme': () => {
void requestActiveTab ('theme')
['settings.theme']: () => {
requestActiveTab ('theme')
},
'settings.keyboard': () => {
void requestActiveTab ('keyboard')
},
})
['settings.keyboard']: () => {
requestActiveTab ('keyboard')
} })
const themeSectionProps: ThemeSectionProps = {
draftThemeMode,
@@ -1230,62 +1244,43 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
settingsBaseErrors,
settingsFieldErrors,
onSelectThemeMode: themeMode => {
void requestThemeStateChange (baseState => ({
...baseState,
themeMode,
}))
requestThemeStateChange (baseState => ({ ...baseState, themeMode }))
},
onSelectLightThemeSlotNo: slotNo => {
void requestThemeStateChange (baseState => ({
...baseState,
activeLightThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('light', slotNo),
}))
requestThemeStateChange (baseState => ({
...baseState,
activeLightThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('light', slotNo) }))
},
onSelectDarkThemeSlotNo: slotNo => {
void requestThemeStateChange (baseState => ({
...baseState,
activeDarkThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo),
}))
requestThemeStateChange (baseState => ({
...baseState,
activeDarkThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo) }))
},
onSelectEditingThemeSlot: selection => {
void requestThemeStateChange (baseState => ({
...baseState,
editingThemeSlot: selection,
}))
requestThemeStateChange (baseState => ({ ...baseState, editingThemeSlot: selection }))
},
onThemeTokenChange: (key, value) => {
updateDraftThemeSlot (editingThemeSlot, tokens => ({
...tokens,
[key]: value,
}))
updateDraftThemeSlot (editingThemeSlot, tokens => ({ ...tokens, [key]: value }))
},
onTagColourChange: (category, colour) => {
updateDraftThemeSlot (editingThemeSlot, tokens => ({
...tokens,
tagColours: {
...tokens.tagColours,
[category]: colour,
},
}))
updateDraftThemeSlot (editingThemeSlot, tokens => ({
...tokens, tagColours: { ...tokens.tagColours, [category]: colour } }))
},
onResetThemeSlot: selection => {
const baseTheme =
draftThemeSlots[selection]?.baseTheme
?? selection.split (':')[1] as BuiltinThemeId
updateDraftThemeSlot (
selection,
() => getDefaultUserThemeSlot (
baseTheme,
draftThemeSlots[selection]?.slotNo
?? Number (selection.split (':')[2]) as UserThemeSlotNo,
).tokens,
)
const baseTheme =
draftThemeSlots[selection]?.baseTheme
?? selection.split (':')[1] as BuiltinThemeId
updateDraftThemeSlot (
selection,
() => getDefaultUserThemeSlot (
baseTheme,
(draftThemeSlots[selection]?.slotNo
?? Number (selection.split (':')[2]) as UserThemeSlotNo)).tokens)
},
onThemeSubmit: handleThemeSubmit,
onThemeDiscard: handleThemeDiscard,
}
onThemeDiscard: handleThemeDiscard }
return (
<MainArea>