このコミットが含まれているのは:
2026-07-06 20:46:16 +09:00
コミット c3d2d8e1d1
7個のファイルの変更293行の追加179行の削除
+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>
</>) </>)
} }
+20 -4
ファイルの表示
@@ -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}
+1 -1
ファイルの表示
@@ -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}>
+1 -1
ファイルの表示
@@ -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}>
+18 -9
ファイルの表示
@@ -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>> (
+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
}
+165 -148
ファイルの表示
@@ -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}