設定画面 (#34) #397
+5
-2
@@ -21,6 +21,7 @@ import { applyClientAppearance,
|
|||||||
setCachedUserThemeSlots,
|
setCachedUserThemeSlots,
|
||||||
seedClientThemeMode } from '@/lib/settings'
|
seedClientThemeMode } from '@/lib/settings'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
|
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
|
||||||
|
|
||||||
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||||
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
||||||
@@ -210,8 +211,9 @@ const App: FC = () => {
|
|||||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||||
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<KeyboardShortcutsProvider>
|
|
||||||
<DialogueProvider>
|
<DialogueProvider>
|
||||||
|
<UnsavedChangesGuardProvider>
|
||||||
|
<KeyboardShortcutsProvider>
|
||||||
<MotionConfig
|
<MotionConfig
|
||||||
reducedMotion={
|
reducedMotion={
|
||||||
animationMode === 'normal'
|
animationMode === 'normal'
|
||||||
@@ -232,8 +234,9 @@ const App: FC = () => {
|
|||||||
</MotionConfig>
|
</MotionConfig>
|
||||||
|
|
||||||
<Toaster/>
|
<Toaster/>
|
||||||
</DialogueProvider>
|
|
||||||
</KeyboardShortcutsProvider>
|
</KeyboardShortcutsProvider>
|
||||||
|
</UnsavedChangesGuardProvider>
|
||||||
|
</DialogueProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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'
|
||||||
@@ -35,11 +36,19 @@ 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 (
|
||||||
|
() => typeof to === 'string' ? to : createPath (to),
|
||||||
|
[to],
|
||||||
|
)
|
||||||
const url = useMemo (() => {
|
const url = useMemo (() => {
|
||||||
const path = (typeof to === 'string') ? to : createPath (to)
|
return (new URL (path, window.location.origin)).toString ()
|
||||||
return (new URL (path, location.origin)).toString ()
|
}, [path])
|
||||||
}, [to])
|
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 () => {
|
||||||
@@ -84,6 +93,13 @@ 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)
|
||||||
})
|
})
|
||||||
@@ -106,7 +122,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<a ref={ref}
|
<a ref={ref}
|
||||||
href={typeof to === 'string' ? to : createPath (to)}
|
href={path}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ const BehaviourSettingsSection: FC<Props> = (
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="destructive"
|
||||||
disabled={hasUnsavedChanges === false}
|
disabled={hasUnsavedChanges === false}
|
||||||
onClick={handleDiscard}>
|
onClick={handleDiscard}>
|
||||||
変更を破棄
|
変更を破棄
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ const KeyboardSettingsSection: FC<Props> = (
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="destructive"
|
||||||
disabled={!(hasUnsavedChanges)}
|
disabled={!(hasUnsavedChanges)}
|
||||||
onClick={handleDiscard}>
|
onClick={handleDiscard}>
|
||||||
変更を破棄
|
変更を破棄
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
getEffectiveKeyBindings,
|
getEffectiveKeyBindings,
|
||||||
setClientKeyboardSettings,
|
setClientKeyboardSettings,
|
||||||
} from '@/lib/settings'
|
} from '@/lib/settings'
|
||||||
|
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
KeyBinding,
|
KeyBinding,
|
||||||
@@ -96,6 +97,7 @@ 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 ())
|
||||||
@@ -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> (
|
const builtinHandlers = useMemo<ShortcutHandlers> (
|
||||||
() => ({
|
() => ({
|
||||||
'global.openShortcutHelp': () => {
|
'global.openShortcutHelp': () => {
|
||||||
@@ -154,16 +163,16 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
|||||||
'global.focusSearch': () => {
|
'global.focusSearch': () => {
|
||||||
focusSearchTarget ()
|
focusSearchTarget ()
|
||||||
},
|
},
|
||||||
'navigation.posts': () => navigate ('/posts'),
|
'navigation.posts': () => guardedNavigate ('/posts'),
|
||||||
'navigation.tags': () => navigate ('/tags'),
|
'navigation.tags': () => guardedNavigate ('/tags'),
|
||||||
'navigation.materials': () => navigate ('/materials'),
|
'navigation.materials': () => guardedNavigate ('/materials'),
|
||||||
'navigation.wiki': () => navigate ('/wiki'),
|
'navigation.wiki': () => guardedNavigate ('/wiki'),
|
||||||
'settings.account': () => navigate ('/users/settings?tab=account'),
|
'settings.account': () => guardedNavigate ('/users/settings?tab=account'),
|
||||||
'settings.theme': () => navigate ('/users/settings?tab=theme'),
|
'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'),
|
||||||
'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'),
|
'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'),
|
||||||
'settings.behavior': () => navigate ('/users/settings?tab=behavior'),
|
'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'),
|
||||||
}),
|
}),
|
||||||
[navigate],
|
[guardedNavigate],
|
||||||
)
|
)
|
||||||
|
|
||||||
const availableActionIds = useMemo<Set<ShortcutActionId>> (
|
const availableActionIds = useMemo<Set<ShortcutActionId>> (
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
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 FieldError from '@/components/common/FieldError'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
getClientThemeMode,
|
getClientThemeMode,
|
||||||
getDefaultUserThemeSlot,
|
getDefaultUserThemeSlot,
|
||||||
hasStoredClientThemeSelection,
|
hasStoredClientThemeSelection,
|
||||||
getUserThemeSlotSelection,
|
|
||||||
normaliseUserThemeSlots,
|
normaliseUserThemeSlots,
|
||||||
resolveDisplayedTheme,
|
resolveDisplayedTheme,
|
||||||
setCachedUserThemeSlots,
|
setCachedUserThemeSlots,
|
||||||
@@ -36,6 +35,7 @@ import {
|
|||||||
upsertUserThemeSlot,
|
upsertUserThemeSlot,
|
||||||
USER_THEME_SLOT_SELECTIONS,
|
USER_THEME_SLOT_SELECTIONS,
|
||||||
} from '@/lib/settings'
|
} from '@/lib/settings'
|
||||||
|
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
||||||
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||||
import { cn, inputClass } from '@/lib/utils'
|
import { cn, inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
@@ -99,7 +99,7 @@ type ThemeSectionProps = {
|
|||||||
draftThemeMode: ClientThemeMode
|
draftThemeMode: ClientThemeMode
|
||||||
draftActiveLightThemeSlotNo: UserThemeSlotNo
|
draftActiveLightThemeSlotNo: UserThemeSlotNo
|
||||||
draftActiveDarkThemeSlotNo: UserThemeSlotNo
|
draftActiveDarkThemeSlotNo: UserThemeSlotNo
|
||||||
editingThemeSlot: UserThemeSlotSelection
|
currentDisplayedThemeSlot: UserThemeSlotSelection
|
||||||
selectedTheme: ClientTheme
|
selectedTheme: ClientTheme
|
||||||
displayedThemeLabel: string
|
displayedThemeLabel: string
|
||||||
systemThemeLabel: string
|
systemThemeLabel: string
|
||||||
@@ -109,10 +109,9 @@ type ThemeSectionProps = {
|
|||||||
onSelectThemeMode: (themeMode: ClientThemeMode) => void
|
onSelectThemeMode: (themeMode: ClientThemeMode) => void
|
||||||
onSelectLightThemeSlotNo: (slotNo: UserThemeSlotNo) => void
|
onSelectLightThemeSlotNo: (slotNo: UserThemeSlotNo) => void
|
||||||
onSelectDarkThemeSlotNo: (slotNo: UserThemeSlotNo) => void
|
onSelectDarkThemeSlotNo: (slotNo: UserThemeSlotNo) => void
|
||||||
onSelectEditingThemeSlot: (selection: UserThemeSlotSelection) => void
|
|
||||||
onThemeTokenChange: (key: ThemeTokenKey, value: string) => void
|
onThemeTokenChange: (key: ThemeTokenKey, value: string) => void
|
||||||
onTagColourChange: (category: Category, colour: string) => void
|
onTagColourChange: (category: Category, colour: string) => void
|
||||||
onResetThemeSlot: (selection: UserThemeSlotSelection) => void
|
onResetThemeSlot: () => void
|
||||||
onThemeSubmit: () => void
|
onThemeSubmit: () => void
|
||||||
onThemeDiscard: () => void }
|
onThemeDiscard: () => void }
|
||||||
|
|
||||||
@@ -124,8 +123,16 @@ type ThemeDraftState = {
|
|||||||
themeMode: ClientThemeMode
|
themeMode: ClientThemeMode
|
||||||
activeLightThemeSlotNo: UserThemeSlotNo
|
activeLightThemeSlotNo: UserThemeSlotNo
|
||||||
activeDarkThemeSlotNo: UserThemeSlotNo
|
activeDarkThemeSlotNo: UserThemeSlotNo
|
||||||
themeSlots: UserThemeSlotMap
|
themeSlots: UserThemeSlotMap }
|
||||||
editingThemeSlot: UserThemeSlotSelection }
|
|
||||||
|
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[] = [
|
const tabs: TabSpec[] = [
|
||||||
{ id: 'account', label: 'アカウント' },
|
{ id: 'account', label: 'アカウント' },
|
||||||
@@ -138,6 +145,11 @@ const themeModeSelections: ThemeModeItem[] = [
|
|||||||
{ id: 'light', label: 'ライト' },
|
{ id: 'light', label: 'ライト' },
|
||||||
{ id: 'dark', label: 'ダーク' } ]
|
{ id: 'dark', label: 'ダーク' } ]
|
||||||
|
|
||||||
|
const themeSlotNoSelections: ThemeSegmentedOption<UserThemeSlotNo>[] = [
|
||||||
|
{ value: 1, label: '1' },
|
||||||
|
{ value: 2, label: '2' },
|
||||||
|
{ value: 3, label: '3' } ]
|
||||||
|
|
||||||
const sectionClassName =
|
const sectionClassName =
|
||||||
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
'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 = (
|
const previewTag = (
|
||||||
id: number,
|
id: number,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -221,7 +259,6 @@ const parseTab = (search: string): SettingsTab => {
|
|||||||
|
|
||||||
const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }`
|
const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }`
|
||||||
const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }`
|
const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }`
|
||||||
const SETTINGS_PATH = '/users/settings'
|
|
||||||
|
|
||||||
const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
|
const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
|
||||||
Object.fromEntries (
|
Object.fromEntries (
|
||||||
@@ -249,6 +286,27 @@ const ensureCompleteThemeSlots = (
|
|||||||
{ ...themeSlots },
|
{ ...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 = (
|
const serialiseThemeDraft = (
|
||||||
themeMode: ClientThemeMode,
|
themeMode: ClientThemeMode,
|
||||||
activeLightThemeSlotNo: UserThemeSlotNo,
|
activeLightThemeSlotNo: UserThemeSlotNo,
|
||||||
@@ -261,7 +319,7 @@ const serialiseThemeDraft = (
|
|||||||
activeDarkThemeSlotNo,
|
activeDarkThemeSlotNo,
|
||||||
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
|
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
|
||||||
selection,
|
selection,
|
||||||
tokens: themeSlots[selection]?.tokens,
|
tokens: comparableThemeTokensForSelection (themeSlots, selection),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -454,7 +512,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|||||||
{ draftThemeMode,
|
{ draftThemeMode,
|
||||||
draftActiveLightThemeSlotNo,
|
draftActiveLightThemeSlotNo,
|
||||||
draftActiveDarkThemeSlotNo,
|
draftActiveDarkThemeSlotNo,
|
||||||
editingThemeSlot,
|
currentDisplayedThemeSlot,
|
||||||
selectedTheme,
|
selectedTheme,
|
||||||
displayedThemeLabel,
|
displayedThemeLabel,
|
||||||
systemThemeLabel,
|
systemThemeLabel,
|
||||||
@@ -464,15 +522,12 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|||||||
onSelectThemeMode,
|
onSelectThemeMode,
|
||||||
onSelectLightThemeSlotNo,
|
onSelectLightThemeSlotNo,
|
||||||
onSelectDarkThemeSlotNo,
|
onSelectDarkThemeSlotNo,
|
||||||
onSelectEditingThemeSlot,
|
|
||||||
onThemeTokenChange,
|
onThemeTokenChange,
|
||||||
onTagColourChange,
|
onTagColourChange,
|
||||||
onResetThemeSlot,
|
onResetThemeSlot,
|
||||||
onThemeSubmit,
|
onThemeSubmit,
|
||||||
onThemeDiscard },
|
onThemeDiscard },
|
||||||
) => {
|
) => {
|
||||||
const currentEditableSelection = editingThemeSlot
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={sectionClassName}>
|
<section className={sectionClassName}>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
@@ -486,7 +541,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|||||||
システム設定: {systemThemeLabel}
|
システム設定: {systemThemeLabel}
|
||||||
</p>)}
|
</p>)}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
編輯対象: {themeLabelBySelection (currentEditableSelection)}
|
保存先: {themeLabelBySelection (currentDisplayedThemeSlot)}
|
||||||
</p>
|
</p>
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && (
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="destructive"
|
||||||
disabled={!(hasUnsavedChanges)}
|
disabled={!(hasUnsavedChanges)}
|
||||||
onClick={onThemeDiscard}>
|
onClick={onThemeDiscard}>
|
||||||
変更を破棄
|
変更を破棄
|
||||||
@@ -506,7 +561,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!(hasUnsavedChanges)}
|
disabled={!(hasUnsavedChanges)}
|
||||||
onClick={onThemeSubmit}>
|
onClick={onThemeSubmit}>
|
||||||
保存
|
{`${ themeLabelBySelection (currentDisplayedThemeSlot) } に保存`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -517,29 +572,23 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|||||||
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
|
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>表示モード</Label>
|
<Label>表示モード</Label>
|
||||||
<select
|
<ThemeSegmentedControl
|
||||||
className={inputClass (false)}
|
|
||||||
value={draftThemeMode}
|
value={draftThemeMode}
|
||||||
onChange={ev =>
|
options={themeModeSelections.map (selection => ({
|
||||||
onSelectThemeMode (ev.target.value as ClientThemeMode)}>
|
value: selection.id,
|
||||||
{themeModeSelections.map (selection => (
|
label: selection.label }))}
|
||||||
<option key={selection.id} value={selection.id}>
|
onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
|
||||||
{selection.label}
|
|
||||||
</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>ライト用テーマ</Label>
|
<Label>ライト用テーマ</Label>
|
||||||
<select
|
<select
|
||||||
className={inputClass (false)}
|
className={cn (inputClass (false), 'w-full')}
|
||||||
value={draftActiveLightThemeSlotNo}
|
value={draftActiveLightThemeSlotNo}
|
||||||
onChange={ev =>
|
onChange={ev =>
|
||||||
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
|
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
|
||||||
{themeSelections
|
{themeSlotNoSelections.map (selection => (
|
||||||
.filter (selection => selection.baseTheme === 'light')
|
<option key={selection.value} value={selection.value}>
|
||||||
.map (selection => (
|
|
||||||
<option key={selection.id} value={selection.slotNo}>
|
|
||||||
{selection.label}
|
{selection.label}
|
||||||
</option>))}
|
</option>))}
|
||||||
</select>
|
</select>
|
||||||
@@ -548,45 +597,28 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>ダーク用テーマ</Label>
|
<Label>ダーク用テーマ</Label>
|
||||||
<select
|
<select
|
||||||
className={inputClass (false)}
|
className={cn (inputClass (false), 'w-full')}
|
||||||
value={draftActiveDarkThemeSlotNo}
|
value={draftActiveDarkThemeSlotNo}
|
||||||
onChange={ev =>
|
onChange={ev =>
|
||||||
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
|
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
|
||||||
{themeSelections
|
{themeSlotNoSelections.map (selection => (
|
||||||
.filter (selection => selection.baseTheme === 'dark')
|
<option key={selection.value} value={selection.value}>
|
||||||
.map (selection => (
|
|
||||||
<option key={selection.id} value={selection.slotNo}>
|
|
||||||
{selection.label}
|
{selection.label}
|
||||||
</option>))}
|
</option>))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap items-center gap-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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
onClick={() => onResetThemeSlot (currentEditableSelection)}>
|
onClick={onResetThemeSlot}>
|
||||||
既定に戻す
|
既定に戻す
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
<p>変更は保存時に反映されます。</p>
|
<p>保存するまで反映は確定しません。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
|
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
|
||||||
@@ -672,6 +704,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
const { registerUnsavedChangesSource } = useUnsavedChangesGuard ()
|
||||||
|
|
||||||
const [inheritVsbl, setInheritVsbl] = useState (false)
|
const [inheritVsbl, setInheritVsbl] = useState (false)
|
||||||
const [loading, setLoading] = useState (true)
|
const [loading, setLoading] = useState (true)
|
||||||
@@ -693,10 +726,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
||||||
const [draftThemeSlots, setDraftThemeSlots] =
|
const [draftThemeSlots, setDraftThemeSlots] =
|
||||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
||||||
const [savedEditingThemeSlot, setSavedEditingThemeSlot] =
|
|
||||||
useState<UserThemeSlotSelection> ('user:light:1')
|
|
||||||
const [editingThemeSlot, setEditingThemeSlot] =
|
|
||||||
useState<UserThemeSlotSelection> ('user:light:1')
|
|
||||||
const [dirtyStates, setDirtyStates] =
|
const [dirtyStates, setDirtyStates] =
|
||||||
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
|
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
|
||||||
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
||||||
@@ -724,7 +753,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
const selectedTheme = useMemo<ClientTheme> (
|
const selectedTheme = useMemo<ClientTheme> (
|
||||||
() => {
|
() => {
|
||||||
const currentSelection =
|
const currentSelection =
|
||||||
themeSelections.find (selection => selection.id === editingThemeSlot)
|
themeSelections.find (
|
||||||
|
selection => selection.id === displayedThemeSelection.selection,
|
||||||
|
)
|
||||||
?? themeSelections[0]
|
?? themeSelections[0]
|
||||||
const themeSlot =
|
const themeSlot =
|
||||||
draftThemeSlots[currentSelection.id]
|
draftThemeSlots[currentSelection.id]
|
||||||
@@ -736,25 +767,23 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
baseTheme: currentSelection.baseTheme,
|
baseTheme: currentSelection.baseTheme,
|
||||||
tokens: themeSlot.tokens }
|
tokens: themeSlot.tokens }
|
||||||
},
|
},
|
||||||
[draftThemeSlots, editingThemeSlot])
|
[displayedThemeSelection.selection, draftThemeSlots])
|
||||||
const hasUnsavedThemeChanges = useMemo (
|
const hasUnsavedThemeChanges = useMemo (
|
||||||
() =>
|
() =>
|
||||||
serialiseThemeDraft (draftThemeMode,
|
serialiseThemeDraft (draftThemeMode,
|
||||||
draftActiveLightThemeSlotNo,
|
draftActiveLightThemeSlotNo,
|
||||||
draftActiveDarkThemeSlotNo,
|
draftActiveDarkThemeSlotNo,
|
||||||
draftThemeSlots)
|
draftThemeSlots)
|
||||||
!== (serialiseThemeDraft (savedThemeMode,
|
!== serialiseThemeDraft (savedThemeMode,
|
||||||
savedActiveLightThemeSlotNo,
|
savedActiveLightThemeSlotNo,
|
||||||
savedActiveDarkThemeSlotNo,
|
savedActiveDarkThemeSlotNo,
|
||||||
savedThemeSlots)),
|
savedThemeSlots),
|
||||||
[draftActiveDarkThemeSlotNo,
|
[draftActiveDarkThemeSlotNo,
|
||||||
draftActiveLightThemeSlotNo,
|
draftActiveLightThemeSlotNo,
|
||||||
draftThemeMode,
|
draftThemeMode,
|
||||||
draftThemeSlots,
|
draftThemeSlots,
|
||||||
editingThemeSlot,
|
|
||||||
savedActiveDarkThemeSlotNo,
|
savedActiveDarkThemeSlotNo,
|
||||||
savedActiveLightThemeSlotNo,
|
savedActiveLightThemeSlotNo,
|
||||||
savedEditingThemeSlot,
|
|
||||||
savedThemeMode,
|
savedThemeMode,
|
||||||
savedThemeSlots])
|
savedThemeSlots])
|
||||||
const savedName = user?.name ?? ''
|
const savedName = user?.name ?? ''
|
||||||
@@ -812,18 +841,15 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
activeLightThemeSlotNo = draftActiveLightThemeSlotNo,
|
activeLightThemeSlotNo = draftActiveLightThemeSlotNo,
|
||||||
activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo,
|
activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo,
|
||||||
themeSlots = draftThemeSlots,
|
themeSlots = draftThemeSlots,
|
||||||
nextEditingThemeSlot = editingThemeSlot,
|
|
||||||
}: { themeMode?: ClientThemeMode
|
}: { themeMode?: ClientThemeMode
|
||||||
activeLightThemeSlotNo?: UserThemeSlotNo
|
activeLightThemeSlotNo?: UserThemeSlotNo
|
||||||
activeDarkThemeSlotNo?: UserThemeSlotNo
|
activeDarkThemeSlotNo?: UserThemeSlotNo
|
||||||
themeSlots?: UserThemeSlotMap
|
themeSlots?: UserThemeSlotMap },
|
||||||
nextEditingThemeSlot?: UserThemeSlotSelection },
|
|
||||||
) => {
|
) => {
|
||||||
setDraftThemeMode (themeMode)
|
setDraftThemeMode (themeMode)
|
||||||
setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
|
setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
|
||||||
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
|
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
|
||||||
setDraftThemeSlots (themeSlots)
|
setDraftThemeSlots (themeSlots)
|
||||||
setEditingThemeSlot (nextEditingThemeSlot)
|
|
||||||
setCachedUserThemeSlots (themeSlots)
|
setCachedUserThemeSlots (themeSlots)
|
||||||
applyThemeAppearanceState ({
|
applyThemeAppearanceState ({
|
||||||
themeMode,
|
themeMode,
|
||||||
@@ -840,22 +866,19 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
themeMode: draftThemeMode,
|
themeMode: draftThemeMode,
|
||||||
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
||||||
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
|
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
|
||||||
themeSlots: draftThemeSlots,
|
themeSlots: draftThemeSlots })
|
||||||
editingThemeSlot })
|
|
||||||
|
|
||||||
const savedThemeDraftState = (): ThemeDraftState => ({
|
const savedThemeDraftState = (): ThemeDraftState => ({
|
||||||
themeMode: savedThemeMode,
|
themeMode: savedThemeMode,
|
||||||
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
|
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
|
||||||
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
|
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
|
||||||
themeSlots: savedThemeSlots,
|
themeSlots: savedThemeSlots })
|
||||||
editingThemeSlot: savedEditingThemeSlot })
|
|
||||||
|
|
||||||
const discardThemeChanges = () => {
|
const discardThemeChanges = () => {
|
||||||
setDraftThemeMode (savedThemeMode)
|
setDraftThemeMode (savedThemeMode)
|
||||||
setDraftActiveLightThemeSlotNo (savedActiveLightThemeSlotNo)
|
setDraftActiveLightThemeSlotNo (savedActiveLightThemeSlotNo)
|
||||||
setDraftActiveDarkThemeSlotNo (savedActiveDarkThemeSlotNo)
|
setDraftActiveDarkThemeSlotNo (savedActiveDarkThemeSlotNo)
|
||||||
setDraftThemeSlots (savedThemeSlots)
|
setDraftThemeSlots (savedThemeSlots)
|
||||||
setEditingThemeSlot (savedEditingThemeSlot)
|
|
||||||
setCachedUserThemeSlots (savedThemeSlots)
|
setCachedUserThemeSlots (savedThemeSlots)
|
||||||
setClientThemeAppearance ({
|
setClientThemeAppearance ({
|
||||||
themeMode: savedThemeMode,
|
themeMode: savedThemeMode,
|
||||||
@@ -864,6 +887,18 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
applyClientAppearance (savedThemeSlots)
|
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 discardCurrentTabChanges = async (): Promise<void> => {
|
||||||
const currentDirtyState = dirtyStates[activeTab]
|
const currentDirtyState = dirtyStates[activeTab]
|
||||||
|
|
||||||
@@ -903,10 +938,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
cancelText = 'このまま編集する',
|
cancelText = 'このまま編集する',
|
||||||
confirmText = '変更を破棄して切り替える',
|
confirmText = '変更を破棄して切り替える',
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const baseState =
|
const baseState = currentThemeDraftState ()
|
||||||
hasUnsavedThemeChanges
|
|
||||||
? savedThemeDraftState ()
|
|
||||||
: currentThemeDraftState ()
|
|
||||||
|
|
||||||
if (hasUnsavedThemeChanges)
|
if (hasUnsavedThemeChanges)
|
||||||
{
|
{
|
||||||
@@ -916,52 +948,52 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
confirmText)
|
confirmText)
|
||||||
if (!(confirmed))
|
if (!(confirmed))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
discardThemeChanges ()
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextState = buildNextState (baseState)
|
const nextState = buildNextState (
|
||||||
|
hasUnsavedThemeChanges
|
||||||
|
? savedThemeDraftState ()
|
||||||
|
: baseState)
|
||||||
|
|
||||||
applyDraftThemeState ({
|
applyDraftThemeState ({
|
||||||
themeMode: nextState.themeMode,
|
themeMode: nextState.themeMode,
|
||||||
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
|
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
|
||||||
activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
|
activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
|
||||||
themeSlots: nextState.themeSlots,
|
themeSlots: nextState.themeSlots })
|
||||||
nextEditingThemeSlot: nextState.editingThemeSlot })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeBlocker = useBlocker (({ currentLocation, nextLocation }) =>
|
const requestThemeMode = async (
|
||||||
hasPageUnsavedChanges
|
themeMode: ClientThemeMode,
|
||||||
&& currentLocation.pathname === SETTINGS_PATH
|
): Promise<void> => {
|
||||||
&& nextLocation.pathname !== currentLocation.pathname)
|
if (themeMode === draftThemeMode)
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (routeBlocker.state !== 'blocked')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
let active = true
|
await requestThemeStateChange (baseState => ({ ...baseState, themeMode }))
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
const confirmed = await confirmDiscardChanges (
|
|
||||||
'このまま移動すると、保存していない変更は失われます。',
|
|
||||||
'このページに残る',
|
|
||||||
'変更を破棄して移動',
|
|
||||||
)
|
|
||||||
if (!(active))
|
|
||||||
return
|
|
||||||
|
|
||||||
if (!(confirmed))
|
|
||||||
{
|
|
||||||
routeBlocker.reset ()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await discardCurrentTabChanges ()
|
const requestLightThemeSlotNo = async (
|
||||||
routeBlocker.proceed ()
|
slotNo: UserThemeSlotNo,
|
||||||
}) ()
|
): Promise<void> => {
|
||||||
|
if (slotNo === draftActiveLightThemeSlotNo)
|
||||||
|
return
|
||||||
|
|
||||||
return () => {
|
await requestThemeStateChange (baseState => ({
|
||||||
active = false
|
...baseState,
|
||||||
|
activeLightThemeSlotNo: slotNo }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestDarkThemeSlotNo = async (
|
||||||
|
slotNo: UserThemeSlotNo,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (slotNo === draftActiveDarkThemeSlotNo)
|
||||||
|
return
|
||||||
|
|
||||||
|
await requestThemeStateChange (baseState => ({
|
||||||
|
...baseState,
|
||||||
|
activeDarkThemeSlotNo: slotNo }))
|
||||||
}
|
}
|
||||||
}, [routeBlocker])
|
|
||||||
|
|
||||||
useBeforeUnload (event => {
|
useBeforeUnload (event => {
|
||||||
if (!(hasPageUnsavedChanges))
|
if (!(hasPageUnsavedChanges))
|
||||||
@@ -972,9 +1004,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
}, { capture: true })
|
}, { capture: true })
|
||||||
|
|
||||||
const updateDraftThemeSlot = (
|
const updateDraftThemeSlot = (
|
||||||
selection: UserThemeSlotSelection,
|
|
||||||
updater: (tokens: ThemeTokens) => ThemeTokens,
|
updater: (tokens: ThemeTokens) => ThemeTokens,
|
||||||
) => {
|
) => {
|
||||||
|
const selection = displayedThemeSelection.selection
|
||||||
const currentThemeSlot =
|
const currentThemeSlot =
|
||||||
draftThemeSlots[selection]
|
draftThemeSlots[selection]
|
||||||
?? ensureCompleteThemeSlots ({ })[selection]
|
?? ensureCompleteThemeSlots ({ })[selection]
|
||||||
@@ -982,7 +1014,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
...draftThemeSlots,
|
...draftThemeSlots,
|
||||||
[selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } }
|
[selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } }
|
||||||
|
|
||||||
applyDraftThemeState ({ themeSlots: nextThemeSlots, nextEditingThemeSlot: selection })
|
applyDraftThemeState ({ themeSlots: nextThemeSlots })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
@@ -1044,21 +1076,13 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
const selection = displayedThemeSelection.selection
|
||||||
let nextSavedThemeSlots = { ...savedThemeSlots }
|
let nextSavedThemeSlots = { ...savedThemeSlots }
|
||||||
|
|
||||||
for (const selection of USER_THEME_SLOT_SELECTIONS)
|
|
||||||
{
|
|
||||||
const draftThemeSlot = draftThemeSlots[selection]
|
const draftThemeSlot = draftThemeSlots[selection]
|
||||||
const savedThemeSlot = savedThemeSlots[selection]
|
const savedThemeSlot = savedThemeSlots[selection]
|
||||||
const requiresPersist =
|
|
||||||
selection === editingThemeSlot
|
|
||||||
&& savedThemeSlot?.createdAt == null
|
|
||||||
|
|
||||||
if ((JSON.stringify (draftThemeSlot?.tokens)
|
|
||||||
=== JSON.stringify (savedThemeSlot?.tokens))
|
|
||||||
&& !(requiresPersist))
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
if (JSON.stringify (draftThemeSlot?.tokens) !== JSON.stringify (savedThemeSlot?.tokens))
|
||||||
|
{
|
||||||
const persisted = await upsertUserThemeSlot (
|
const persisted = await upsertUserThemeSlot (
|
||||||
draftThemeSlot?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId,
|
draftThemeSlot?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId,
|
||||||
draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo,
|
draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo,
|
||||||
@@ -1075,8 +1099,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
|
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
|
||||||
setSavedThemeSlots (completeThemeSlots)
|
setSavedThemeSlots (completeThemeSlots)
|
||||||
setDraftThemeSlots (completeThemeSlots)
|
setDraftThemeSlots (completeThemeSlots)
|
||||||
setSavedEditingThemeSlot (editingThemeSlot)
|
|
||||||
setEditingThemeSlot (editingThemeSlot)
|
|
||||||
// Display mode and active slot numbers are browser-local state.
|
// Display mode and active slot numbers are browser-local state.
|
||||||
setClientThemeAppearance ({
|
setClientThemeAppearance ({
|
||||||
themeMode: draftThemeMode,
|
themeMode: draftThemeMode,
|
||||||
@@ -1128,10 +1150,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
: settings.theme
|
: settings.theme
|
||||||
const activeLightThemeSlotNo = getClientActiveLightThemeSlotNo ()
|
const activeLightThemeSlotNo = getClientActiveLightThemeSlotNo ()
|
||||||
const activeDarkThemeSlotNo = getClientActiveDarkThemeSlotNo ()
|
const activeDarkThemeSlotNo = getClientActiveDarkThemeSlotNo ()
|
||||||
const initialEditingThemeSlot = resolveDisplayedTheme ({
|
|
||||||
themeMode,
|
|
||||||
activeLightThemeSlotNo,
|
|
||||||
activeDarkThemeSlotNo }, completeThemeSlots).selection
|
|
||||||
|
|
||||||
setSavedThemeMode (themeMode)
|
setSavedThemeMode (themeMode)
|
||||||
setDraftThemeMode (themeMode)
|
setDraftThemeMode (themeMode)
|
||||||
@@ -1141,8 +1159,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
|
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
|
||||||
setSavedThemeSlots (completeThemeSlots)
|
setSavedThemeSlots (completeThemeSlots)
|
||||||
setDraftThemeSlots (completeThemeSlots)
|
setDraftThemeSlots (completeThemeSlots)
|
||||||
setSavedEditingThemeSlot (initialEditingThemeSlot)
|
|
||||||
setEditingThemeSlot (initialEditingThemeSlot)
|
|
||||||
setCachedUserThemeSlots (completeThemeSlots)
|
setCachedUserThemeSlots (completeThemeSlots)
|
||||||
applyThemeAppearanceState ({ themeMode,
|
applyThemeAppearanceState ({ themeMode,
|
||||||
activeLightThemeSlotNo,
|
activeLightThemeSlotNo,
|
||||||
@@ -1218,6 +1234,16 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
discard: handleThemeDiscard })
|
discard: handleThemeDiscard })
|
||||||
}, [hasUnsavedThemeChanges, savedThemeSlots])
|
}, [hasUnsavedThemeChanges, savedThemeSlots])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
registerUnsavedChangesSource ({
|
||||||
|
dirty: hasPageUnsavedChanges,
|
||||||
|
discard: discardAllDirtyChanges })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
registerUnsavedChangesSource (null)
|
||||||
|
}
|
||||||
|
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
|
||||||
|
|
||||||
useKeyboardShortcuts ({
|
useKeyboardShortcuts ({
|
||||||
['settings.account']: () => {
|
['settings.account']: () => {
|
||||||
requestActiveTab ('account')
|
requestActiveTab ('account')
|
||||||
@@ -1236,7 +1262,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
draftThemeMode,
|
draftThemeMode,
|
||||||
draftActiveLightThemeSlotNo,
|
draftActiveLightThemeSlotNo,
|
||||||
draftActiveDarkThemeSlotNo,
|
draftActiveDarkThemeSlotNo,
|
||||||
editingThemeSlot,
|
currentDisplayedThemeSlot: displayedThemeSelection.selection,
|
||||||
selectedTheme,
|
selectedTheme,
|
||||||
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection),
|
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection),
|
||||||
systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト',
|
systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト',
|
||||||
@@ -1244,36 +1270,27 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
settingsBaseErrors,
|
settingsBaseErrors,
|
||||||
settingsFieldErrors,
|
settingsFieldErrors,
|
||||||
onSelectThemeMode: themeMode => {
|
onSelectThemeMode: themeMode => {
|
||||||
requestThemeStateChange (baseState => ({ ...baseState, themeMode }))
|
requestThemeMode (themeMode)
|
||||||
},
|
},
|
||||||
onSelectLightThemeSlotNo: slotNo => {
|
onSelectLightThemeSlotNo: slotNo => {
|
||||||
requestThemeStateChange (baseState => ({
|
requestLightThemeSlotNo (slotNo)
|
||||||
...baseState,
|
|
||||||
activeLightThemeSlotNo: slotNo,
|
|
||||||
editingThemeSlot: getUserThemeSlotSelection ('light', slotNo) }))
|
|
||||||
},
|
},
|
||||||
onSelectDarkThemeSlotNo: slotNo => {
|
onSelectDarkThemeSlotNo: slotNo => {
|
||||||
requestThemeStateChange (baseState => ({
|
requestDarkThemeSlotNo (slotNo)
|
||||||
...baseState,
|
|
||||||
activeDarkThemeSlotNo: slotNo,
|
|
||||||
editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo) }))
|
|
||||||
},
|
|
||||||
onSelectEditingThemeSlot: selection => {
|
|
||||||
requestThemeStateChange (baseState => ({ ...baseState, editingThemeSlot: selection }))
|
|
||||||
},
|
},
|
||||||
onThemeTokenChange: (key, value) => {
|
onThemeTokenChange: (key, value) => {
|
||||||
updateDraftThemeSlot (editingThemeSlot, tokens => ({ ...tokens, [key]: value }))
|
updateDraftThemeSlot (tokens => ({ ...tokens, [key]: value }))
|
||||||
},
|
},
|
||||||
onTagColourChange: (category, colour) => {
|
onTagColourChange: (category, colour) => {
|
||||||
updateDraftThemeSlot (editingThemeSlot, tokens => ({
|
updateDraftThemeSlot (tokens => ({
|
||||||
...tokens, tagColours: { ...tokens.tagColours, [category]: colour } }))
|
...tokens, tagColours: { ...tokens.tagColours, [category]: colour } }))
|
||||||
},
|
},
|
||||||
onResetThemeSlot: selection => {
|
onResetThemeSlot: () => {
|
||||||
|
const selection = displayedThemeSelection.selection
|
||||||
const baseTheme =
|
const baseTheme =
|
||||||
draftThemeSlots[selection]?.baseTheme
|
draftThemeSlots[selection]?.baseTheme
|
||||||
?? selection.split (':')[1] as BuiltinThemeId
|
?? selection.split (':')[1] as BuiltinThemeId
|
||||||
updateDraftThemeSlot (
|
updateDraftThemeSlot (
|
||||||
selection,
|
|
||||||
() => getDefaultUserThemeSlot (
|
() => getDefaultUserThemeSlot (
|
||||||
baseTheme,
|
baseTheme,
|
||||||
(draftThemeSlots[selection]?.slotNo
|
(draftThemeSlots[selection]?.slotNo
|
||||||
@@ -1313,7 +1330,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
: ['border-border bg-background text-foreground']),
|
: ['border-border bg-background text-foreground']),
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void requestActiveTab (tab.id)
|
requestActiveTab (tab.id)
|
||||||
}}>
|
}}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
{settingsTabErrors[tab.id] ? ' !' : ''}
|
{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']),
|
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']),
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void requestActiveTab (tab.id)
|
requestActiveTab (tab.id)
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleTabKeyDown}>
|
onKeyDown={handleTabKeyDown}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする