|
|
|
@@ -1,6 +1,6 @@
|
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { Helmet } from 'react-helmet-async'
|
|
|
|
|
import { useBeforeUnload, useBlocker, useLocation, useNavigate } from 'react-router-dom'
|
|
|
|
|
import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom'
|
|
|
|
|
|
|
|
|
|
import FieldError from '@/components/common/FieldError'
|
|
|
|
|
import FormField from '@/components/common/FormField'
|
|
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
|
|
getClientThemeMode,
|
|
|
|
|
getDefaultUserThemeSlot,
|
|
|
|
|
hasStoredClientThemeSelection,
|
|
|
|
|
getUserThemeSlotSelection,
|
|
|
|
|
normaliseUserThemeSlots,
|
|
|
|
|
resolveDisplayedTheme,
|
|
|
|
|
setCachedUserThemeSlots,
|
|
|
|
@@ -36,6 +35,7 @@ import {
|
|
|
|
|
upsertUserThemeSlot,
|
|
|
|
|
USER_THEME_SLOT_SELECTIONS,
|
|
|
|
|
} from '@/lib/settings'
|
|
|
|
|
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
|
|
|
|
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
|
|
|
|
import { cn, inputClass } from '@/lib/utils'
|
|
|
|
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
|
|
|
@@ -99,7 +99,7 @@ type ThemeSectionProps = {
|
|
|
|
|
draftThemeMode: ClientThemeMode
|
|
|
|
|
draftActiveLightThemeSlotNo: UserThemeSlotNo
|
|
|
|
|
draftActiveDarkThemeSlotNo: UserThemeSlotNo
|
|
|
|
|
editingThemeSlot: UserThemeSlotSelection
|
|
|
|
|
currentDisplayedThemeSlot: UserThemeSlotSelection
|
|
|
|
|
selectedTheme: ClientTheme
|
|
|
|
|
displayedThemeLabel: string
|
|
|
|
|
systemThemeLabel: string
|
|
|
|
@@ -109,10 +109,9 @@ type ThemeSectionProps = {
|
|
|
|
|
onSelectThemeMode: (themeMode: ClientThemeMode) => void
|
|
|
|
|
onSelectLightThemeSlotNo: (slotNo: UserThemeSlotNo) => void
|
|
|
|
|
onSelectDarkThemeSlotNo: (slotNo: UserThemeSlotNo) => void
|
|
|
|
|
onSelectEditingThemeSlot: (selection: UserThemeSlotSelection) => void
|
|
|
|
|
onThemeTokenChange: (key: ThemeTokenKey, value: string) => void
|
|
|
|
|
onTagColourChange: (category: Category, colour: string) => void
|
|
|
|
|
onResetThemeSlot: (selection: UserThemeSlotSelection) => void
|
|
|
|
|
onResetThemeSlot: () => void
|
|
|
|
|
onThemeSubmit: () => void
|
|
|
|
|
onThemeDiscard: () => void }
|
|
|
|
|
|
|
|
|
@@ -124,8 +123,16 @@ type ThemeDraftState = {
|
|
|
|
|
themeMode: ClientThemeMode
|
|
|
|
|
activeLightThemeSlotNo: UserThemeSlotNo
|
|
|
|
|
activeDarkThemeSlotNo: UserThemeSlotNo
|
|
|
|
|
themeSlots: UserThemeSlotMap
|
|
|
|
|
editingThemeSlot: UserThemeSlotSelection }
|
|
|
|
|
themeSlots: UserThemeSlotMap }
|
|
|
|
|
|
|
|
|
|
type ThemeSegmentedOption<T extends string | number> = {
|
|
|
|
|
value: T
|
|
|
|
|
label: string }
|
|
|
|
|
|
|
|
|
|
type ThemeSegmentedControlProps<T extends string | number> = {
|
|
|
|
|
value: T
|
|
|
|
|
options: ThemeSegmentedOption<T>[]
|
|
|
|
|
onChange: (value: T) => void }
|
|
|
|
|
|
|
|
|
|
const tabs: TabSpec[] = [
|
|
|
|
|
{ id: 'account', label: 'アカウント' },
|
|
|
|
@@ -138,6 +145,11 @@ const themeModeSelections: ThemeModeItem[] = [
|
|
|
|
|
{ id: 'light', label: 'ライト' },
|
|
|
|
|
{ id: 'dark', label: 'ダーク' } ]
|
|
|
|
|
|
|
|
|
|
const themeSlotNoSelections: ThemeSegmentedOption<UserThemeSlotNo>[] = [
|
|
|
|
|
{ value: 1, label: '1' },
|
|
|
|
|
{ value: 2, label: '2' },
|
|
|
|
|
{ value: 3, label: '3' } ]
|
|
|
|
|
|
|
|
|
|
const sectionClassName =
|
|
|
|
|
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
|
|
|
|
|
|
|
|
@@ -181,6 +193,32 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
|
|
|
|
|
] },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const ThemeSegmentedControl = <T extends string | number,> (
|
|
|
|
|
{ value, options, onChange }: ThemeSegmentedControlProps<T>,
|
|
|
|
|
) => (
|
|
|
|
|
<div
|
|
|
|
|
role="radiogroup"
|
|
|
|
|
className="flex flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
|
|
|
|
|
{options.map (option => (
|
|
|
|
|
<button
|
|
|
|
|
key={String (option.value)}
|
|
|
|
|
type="button"
|
|
|
|
|
role="radio"
|
|
|
|
|
aria-checked={value === option.value}
|
|
|
|
|
className={cn (
|
|
|
|
|
'min-w-0 rounded-lg px-3 py-2 text-sm font-medium',
|
|
|
|
|
'transition-colors focus-visible:outline-none focus-visible:ring-2',
|
|
|
|
|
'focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
|
|
|
value === option.value
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/40'
|
|
|
|
|
: 'bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground',
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => onChange (option.value)}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</button>))}
|
|
|
|
|
</div>)
|
|
|
|
|
|
|
|
|
|
const previewTag = (
|
|
|
|
|
id: number,
|
|
|
|
|
name: string,
|
|
|
|
@@ -221,7 +259,6 @@ const parseTab = (search: string): SettingsTab => {
|
|
|
|
|
|
|
|
|
|
const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }`
|
|
|
|
|
const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }`
|
|
|
|
|
const SETTINGS_PATH = '/users/settings'
|
|
|
|
|
|
|
|
|
|
const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
|
|
|
|
|
Object.fromEntries (
|
|
|
|
@@ -249,6 +286,27 @@ const ensureCompleteThemeSlots = (
|
|
|
|
|
{ ...themeSlots },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const comparableThemeTokensForSelection = (
|
|
|
|
|
themeSlots: UserThemeSlotMap,
|
|
|
|
|
selection: UserThemeSlotSelection,
|
|
|
|
|
): ThemeTokens => {
|
|
|
|
|
const [_, baseTheme, slotNo] = selection.split (':')
|
|
|
|
|
const fallback =
|
|
|
|
|
getDefaultUserThemeSlot (
|
|
|
|
|
baseTheme as BuiltinThemeId,
|
|
|
|
|
Number (slotNo) as UserThemeSlotNo,
|
|
|
|
|
).tokens
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...fallback,
|
|
|
|
|
...(themeSlots[selection]?.tokens ?? { }),
|
|
|
|
|
tagColours: {
|
|
|
|
|
...fallback.tagColours,
|
|
|
|
|
...(themeSlots[selection]?.tokens.tagColours ?? { }),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const serialiseThemeDraft = (
|
|
|
|
|
themeMode: ClientThemeMode,
|
|
|
|
|
activeLightThemeSlotNo: UserThemeSlotNo,
|
|
|
|
@@ -261,7 +319,7 @@ const serialiseThemeDraft = (
|
|
|
|
|
activeDarkThemeSlotNo,
|
|
|
|
|
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
|
|
|
|
|
selection,
|
|
|
|
|
tokens: themeSlots[selection]?.tokens,
|
|
|
|
|
tokens: comparableThemeTokensForSelection (themeSlots, selection),
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
@@ -454,7 +512,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|
|
|
|
{ draftThemeMode,
|
|
|
|
|
draftActiveLightThemeSlotNo,
|
|
|
|
|
draftActiveDarkThemeSlotNo,
|
|
|
|
|
editingThemeSlot,
|
|
|
|
|
currentDisplayedThemeSlot,
|
|
|
|
|
selectedTheme,
|
|
|
|
|
displayedThemeLabel,
|
|
|
|
|
systemThemeLabel,
|
|
|
|
@@ -464,15 +522,12 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|
|
|
|
onSelectThemeMode,
|
|
|
|
|
onSelectLightThemeSlotNo,
|
|
|
|
|
onSelectDarkThemeSlotNo,
|
|
|
|
|
onSelectEditingThemeSlot,
|
|
|
|
|
onThemeTokenChange,
|
|
|
|
|
onTagColourChange,
|
|
|
|
|
onResetThemeSlot,
|
|
|
|
|
onThemeSubmit,
|
|
|
|
|
onThemeDiscard },
|
|
|
|
|
) => {
|
|
|
|
|
const currentEditableSelection = editingThemeSlot
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section className={sectionClassName}>
|
|
|
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
|
|
@@ -486,7 +541,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|
|
|
|
システム設定: {systemThemeLabel}
|
|
|
|
|
</p>)}
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
編輯対象: {themeLabelBySelection (currentEditableSelection)}
|
|
|
|
|
保存先: {themeLabelBySelection (currentDisplayedThemeSlot)}
|
|
|
|
|
</p>
|
|
|
|
|
{hasUnsavedChanges && (
|
|
|
|
|
<p className="text-sm text-amber-700 dark:text-amber-300">
|
|
|
|
@@ -497,7 +552,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
variant="destructive"
|
|
|
|
|
disabled={!(hasUnsavedChanges)}
|
|
|
|
|
onClick={onThemeDiscard}>
|
|
|
|
|
変更を破棄
|
|
|
|
@@ -506,7 +561,7 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={!(hasUnsavedChanges)}
|
|
|
|
|
onClick={onThemeSubmit}>
|
|
|
|
|
保存
|
|
|
|
|
{`${ themeLabelBySelection (currentDisplayedThemeSlot) } に保存`}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
@@ -517,76 +572,53 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|
|
|
|
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>表示モード</Label>
|
|
|
|
|
<select
|
|
|
|
|
className={inputClass (false)}
|
|
|
|
|
<ThemeSegmentedControl
|
|
|
|
|
value={draftThemeMode}
|
|
|
|
|
onChange={ev =>
|
|
|
|
|
onSelectThemeMode (ev.target.value as ClientThemeMode)}>
|
|
|
|
|
{themeModeSelections.map (selection => (
|
|
|
|
|
<option key={selection.id} value={selection.id}>
|
|
|
|
|
{selection.label}
|
|
|
|
|
</option>))}
|
|
|
|
|
</select>
|
|
|
|
|
options={themeModeSelections.map (selection => ({
|
|
|
|
|
value: selection.id,
|
|
|
|
|
label: selection.label }))}
|
|
|
|
|
onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>ライト用テーマ</Label>
|
|
|
|
|
<select
|
|
|
|
|
className={inputClass (false)}
|
|
|
|
|
className={cn (inputClass (false), 'w-full')}
|
|
|
|
|
value={draftActiveLightThemeSlotNo}
|
|
|
|
|
onChange={ev =>
|
|
|
|
|
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
|
|
|
|
|
{themeSelections
|
|
|
|
|
.filter (selection => selection.baseTheme === 'light')
|
|
|
|
|
.map (selection => (
|
|
|
|
|
<option key={selection.id} value={selection.slotNo}>
|
|
|
|
|
{selection.label}
|
|
|
|
|
</option>))}
|
|
|
|
|
{themeSlotNoSelections.map (selection => (
|
|
|
|
|
<option key={selection.value} value={selection.value}>
|
|
|
|
|
{selection.label}
|
|
|
|
|
</option>))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>ダーク用テーマ</Label>
|
|
|
|
|
<select
|
|
|
|
|
className={inputClass (false)}
|
|
|
|
|
className={cn (inputClass (false), 'w-full')}
|
|
|
|
|
value={draftActiveDarkThemeSlotNo}
|
|
|
|
|
onChange={ev =>
|
|
|
|
|
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
|
|
|
|
|
{themeSelections
|
|
|
|
|
.filter (selection => selection.baseTheme === 'dark')
|
|
|
|
|
.map (selection => (
|
|
|
|
|
<option key={selection.id} value={selection.slotNo}>
|
|
|
|
|
{selection.label}
|
|
|
|
|
</option>))}
|
|
|
|
|
{themeSlotNoSelections.map (selection => (
|
|
|
|
|
<option key={selection.value} value={selection.value}>
|
|
|
|
|
{selection.label}
|
|
|
|
|
</option>))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="theme-slot-select">編輯対象</Label>
|
|
|
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
|
|
|
<select
|
|
|
|
|
id="theme-slot-select"
|
|
|
|
|
className={cn (inputClass (false), 'w-full min-w-0 sm:w-64')}
|
|
|
|
|
value={currentEditableSelection}
|
|
|
|
|
onChange={ev =>
|
|
|
|
|
onSelectEditingThemeSlot (ev.target.value as UserThemeSlotSelection)}>
|
|
|
|
|
{themeSelections.map (selection => (
|
|
|
|
|
<option key={selection.id} value={selection.id}>
|
|
|
|
|
{selection.label}
|
|
|
|
|
</option>))}
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => onResetThemeSlot (currentEditableSelection)}>
|
|
|
|
|
既定に戻す
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={onResetThemeSlot}>
|
|
|
|
|
既定に戻す
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1 text-sm text-muted-foreground">
|
|
|
|
|
<p>変更は保存時に反映されます。</p>
|
|
|
|
|
<p>保存するまで反映は確定しません。</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
|
|
|
|
@@ -672,6 +704,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
const location = useLocation ()
|
|
|
|
|
const navigate = useNavigate ()
|
|
|
|
|
const dialogue = useDialogue ()
|
|
|
|
|
const { registerUnsavedChangesSource } = useUnsavedChangesGuard ()
|
|
|
|
|
|
|
|
|
|
const [inheritVsbl, setInheritVsbl] = useState (false)
|
|
|
|
|
const [loading, setLoading] = useState (true)
|
|
|
|
@@ -693,10 +726,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
|
|
|
|
const [draftThemeSlots, setDraftThemeSlots] =
|
|
|
|
|
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
|
|
|
|
const [savedEditingThemeSlot, setSavedEditingThemeSlot] =
|
|
|
|
|
useState<UserThemeSlotSelection> ('user:light:1')
|
|
|
|
|
const [editingThemeSlot, setEditingThemeSlot] =
|
|
|
|
|
useState<UserThemeSlotSelection> ('user:light:1')
|
|
|
|
|
const [dirtyStates, setDirtyStates] =
|
|
|
|
|
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
|
|
|
|
|
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
|
|
|
@@ -724,7 +753,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
const selectedTheme = useMemo<ClientTheme> (
|
|
|
|
|
() => {
|
|
|
|
|
const currentSelection =
|
|
|
|
|
themeSelections.find (selection => selection.id === editingThemeSlot)
|
|
|
|
|
themeSelections.find (
|
|
|
|
|
selection => selection.id === displayedThemeSelection.selection,
|
|
|
|
|
)
|
|
|
|
|
?? themeSelections[0]
|
|
|
|
|
const themeSlot =
|
|
|
|
|
draftThemeSlots[currentSelection.id]
|
|
|
|
@@ -736,25 +767,23 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
baseTheme: currentSelection.baseTheme,
|
|
|
|
|
tokens: themeSlot.tokens }
|
|
|
|
|
},
|
|
|
|
|
[draftThemeSlots, editingThemeSlot])
|
|
|
|
|
[displayedThemeSelection.selection, draftThemeSlots])
|
|
|
|
|
const hasUnsavedThemeChanges = useMemo (
|
|
|
|
|
() =>
|
|
|
|
|
serialiseThemeDraft (draftThemeMode,
|
|
|
|
|
draftActiveLightThemeSlotNo,
|
|
|
|
|
draftActiveDarkThemeSlotNo,
|
|
|
|
|
draftThemeSlots)
|
|
|
|
|
!== (serialiseThemeDraft (savedThemeMode,
|
|
|
|
|
savedActiveLightThemeSlotNo,
|
|
|
|
|
savedActiveDarkThemeSlotNo,
|
|
|
|
|
savedThemeSlots)),
|
|
|
|
|
!== serialiseThemeDraft (savedThemeMode,
|
|
|
|
|
savedActiveLightThemeSlotNo,
|
|
|
|
|
savedActiveDarkThemeSlotNo,
|
|
|
|
|
savedThemeSlots),
|
|
|
|
|
[draftActiveDarkThemeSlotNo,
|
|
|
|
|
draftActiveLightThemeSlotNo,
|
|
|
|
|
draftThemeMode,
|
|
|
|
|
draftThemeSlots,
|
|
|
|
|
editingThemeSlot,
|
|
|
|
|
savedActiveDarkThemeSlotNo,
|
|
|
|
|
savedActiveLightThemeSlotNo,
|
|
|
|
|
savedEditingThemeSlot,
|
|
|
|
|
savedThemeMode,
|
|
|
|
|
savedThemeSlots])
|
|
|
|
|
const savedName = user?.name ?? ''
|
|
|
|
@@ -812,18 +841,15 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
activeLightThemeSlotNo = draftActiveLightThemeSlotNo,
|
|
|
|
|
activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo,
|
|
|
|
|
themeSlots = draftThemeSlots,
|
|
|
|
|
nextEditingThemeSlot = editingThemeSlot,
|
|
|
|
|
}: { themeMode?: ClientThemeMode
|
|
|
|
|
activeLightThemeSlotNo?: UserThemeSlotNo
|
|
|
|
|
activeDarkThemeSlotNo?: UserThemeSlotNo
|
|
|
|
|
themeSlots?: UserThemeSlotMap
|
|
|
|
|
nextEditingThemeSlot?: UserThemeSlotSelection },
|
|
|
|
|
themeSlots?: UserThemeSlotMap },
|
|
|
|
|
) => {
|
|
|
|
|
setDraftThemeMode (themeMode)
|
|
|
|
|
setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
|
|
|
|
|
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
|
|
|
|
|
setDraftThemeSlots (themeSlots)
|
|
|
|
|
setEditingThemeSlot (nextEditingThemeSlot)
|
|
|
|
|
setCachedUserThemeSlots (themeSlots)
|
|
|
|
|
applyThemeAppearanceState ({
|
|
|
|
|
themeMode,
|
|
|
|
@@ -836,26 +862,23 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
clearUserValidationErrors ()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentThemeDraftState = (): ThemeDraftState => ({
|
|
|
|
|
const currentThemeDraftState = (): ThemeDraftState => ({
|
|
|
|
|
themeMode: draftThemeMode,
|
|
|
|
|
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
|
|
|
|
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
|
|
|
|
|
themeSlots: draftThemeSlots,
|
|
|
|
|
editingThemeSlot })
|
|
|
|
|
themeSlots: draftThemeSlots })
|
|
|
|
|
|
|
|
|
|
const savedThemeDraftState = (): ThemeDraftState => ({
|
|
|
|
|
themeMode: savedThemeMode,
|
|
|
|
|
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
|
|
|
|
|
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
|
|
|
|
|
themeSlots: savedThemeSlots,
|
|
|
|
|
editingThemeSlot: savedEditingThemeSlot })
|
|
|
|
|
themeSlots: savedThemeSlots })
|
|
|
|
|
|
|
|
|
|
const discardThemeChanges = () => {
|
|
|
|
|
setDraftThemeMode (savedThemeMode)
|
|
|
|
|
setDraftActiveLightThemeSlotNo (savedActiveLightThemeSlotNo)
|
|
|
|
|
setDraftActiveDarkThemeSlotNo (savedActiveDarkThemeSlotNo)
|
|
|
|
|
setDraftThemeSlots (savedThemeSlots)
|
|
|
|
|
setEditingThemeSlot (savedEditingThemeSlot)
|
|
|
|
|
setCachedUserThemeSlots (savedThemeSlots)
|
|
|
|
|
setClientThemeAppearance ({
|
|
|
|
|
themeMode: savedThemeMode,
|
|
|
|
@@ -864,6 +887,18 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
applyClientAppearance (savedThemeSlots)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const discardAllDirtyChanges = async (): Promise<void> => {
|
|
|
|
|
const currentDirtyStates = Object.values (dirtyStates)
|
|
|
|
|
|
|
|
|
|
for (const dirtyState of currentDirtyStates)
|
|
|
|
|
{
|
|
|
|
|
if (dirtyState?.dirty)
|
|
|
|
|
await dirtyState.discard ()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setDirtyStates ({ })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const discardCurrentTabChanges = async (): Promise<void> => {
|
|
|
|
|
const currentDirtyState = dirtyStates[activeTab]
|
|
|
|
|
|
|
|
|
@@ -903,10 +938,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
cancelText = 'このまま編集する',
|
|
|
|
|
confirmText = '変更を破棄して切り替える',
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
const baseState =
|
|
|
|
|
hasUnsavedThemeChanges
|
|
|
|
|
? savedThemeDraftState ()
|
|
|
|
|
: currentThemeDraftState ()
|
|
|
|
|
const baseState = currentThemeDraftState ()
|
|
|
|
|
|
|
|
|
|
if (hasUnsavedThemeChanges)
|
|
|
|
|
{
|
|
|
|
@@ -916,52 +948,52 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
confirmText)
|
|
|
|
|
if (!(confirmed))
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
discardThemeChanges ()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextState = buildNextState (baseState)
|
|
|
|
|
const nextState = buildNextState (
|
|
|
|
|
hasUnsavedThemeChanges
|
|
|
|
|
? savedThemeDraftState ()
|
|
|
|
|
: baseState)
|
|
|
|
|
|
|
|
|
|
applyDraftThemeState ({
|
|
|
|
|
themeMode: nextState.themeMode,
|
|
|
|
|
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
|
|
|
|
|
activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
|
|
|
|
|
themeSlots: nextState.themeSlots,
|
|
|
|
|
nextEditingThemeSlot: nextState.editingThemeSlot })
|
|
|
|
|
themeSlots: nextState.themeSlots })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const routeBlocker = useBlocker (({ currentLocation, nextLocation }) =>
|
|
|
|
|
hasPageUnsavedChanges
|
|
|
|
|
&& currentLocation.pathname === SETTINGS_PATH
|
|
|
|
|
&& nextLocation.pathname !== currentLocation.pathname)
|
|
|
|
|
|
|
|
|
|
useEffect (() => {
|
|
|
|
|
if (routeBlocker.state !== 'blocked')
|
|
|
|
|
const requestThemeMode = async (
|
|
|
|
|
themeMode: ClientThemeMode,
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
if (themeMode === draftThemeMode)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
let active = true
|
|
|
|
|
await requestThemeStateChange (baseState => ({ ...baseState, themeMode }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
const confirmed = await confirmDiscardChanges (
|
|
|
|
|
'このまま移動すると、保存していない変更は失われます。',
|
|
|
|
|
'このページに残る',
|
|
|
|
|
'変更を破棄して移動',
|
|
|
|
|
)
|
|
|
|
|
if (!(active))
|
|
|
|
|
return
|
|
|
|
|
const requestLightThemeSlotNo = async (
|
|
|
|
|
slotNo: UserThemeSlotNo,
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
if (slotNo === draftActiveLightThemeSlotNo)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if (!(confirmed))
|
|
|
|
|
{
|
|
|
|
|
routeBlocker.reset ()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
await requestThemeStateChange (baseState => ({
|
|
|
|
|
...baseState,
|
|
|
|
|
activeLightThemeSlotNo: slotNo }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await discardCurrentTabChanges ()
|
|
|
|
|
routeBlocker.proceed ()
|
|
|
|
|
}) ()
|
|
|
|
|
const requestDarkThemeSlotNo = async (
|
|
|
|
|
slotNo: UserThemeSlotNo,
|
|
|
|
|
): Promise<void> => {
|
|
|
|
|
if (slotNo === draftActiveDarkThemeSlotNo)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
active = false
|
|
|
|
|
}
|
|
|
|
|
}, [routeBlocker])
|
|
|
|
|
await requestThemeStateChange (baseState => ({
|
|
|
|
|
...baseState,
|
|
|
|
|
activeDarkThemeSlotNo: slotNo }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useBeforeUnload (event => {
|
|
|
|
|
if (!(hasPageUnsavedChanges))
|
|
|
|
@@ -972,9 +1004,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
}, { capture: true })
|
|
|
|
|
|
|
|
|
|
const updateDraftThemeSlot = (
|
|
|
|
|
selection: UserThemeSlotSelection,
|
|
|
|
|
updater: (tokens: ThemeTokens) => ThemeTokens,
|
|
|
|
|
) => {
|
|
|
|
|
const selection = displayedThemeSelection.selection
|
|
|
|
|
const currentThemeSlot =
|
|
|
|
|
draftThemeSlots[selection]
|
|
|
|
|
?? ensureCompleteThemeSlots ({ })[selection]
|
|
|
|
@@ -982,7 +1014,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
...draftThemeSlots,
|
|
|
|
|
[selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } }
|
|
|
|
|
|
|
|
|
|
applyDraftThemeState ({ themeSlots: nextThemeSlots, nextEditingThemeSlot: selection })
|
|
|
|
|
applyDraftThemeState ({ themeSlots: nextThemeSlots })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
|
|
@@ -1044,21 +1076,13 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
const selection = displayedThemeSelection.selection
|
|
|
|
|
let nextSavedThemeSlots = { ...savedThemeSlots }
|
|
|
|
|
const draftThemeSlot = draftThemeSlots[selection]
|
|
|
|
|
const savedThemeSlot = savedThemeSlots[selection]
|
|
|
|
|
|
|
|
|
|
for (const selection of USER_THEME_SLOT_SELECTIONS)
|
|
|
|
|
if (JSON.stringify (draftThemeSlot?.tokens) !== JSON.stringify (savedThemeSlot?.tokens))
|
|
|
|
|
{
|
|
|
|
|
const draftThemeSlot = draftThemeSlots[selection]
|
|
|
|
|
const savedThemeSlot = savedThemeSlots[selection]
|
|
|
|
|
const requiresPersist =
|
|
|
|
|
selection === editingThemeSlot
|
|
|
|
|
&& savedThemeSlot?.createdAt == null
|
|
|
|
|
|
|
|
|
|
if ((JSON.stringify (draftThemeSlot?.tokens)
|
|
|
|
|
=== JSON.stringify (savedThemeSlot?.tokens))
|
|
|
|
|
&& !(requiresPersist))
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
const persisted = await upsertUserThemeSlot (
|
|
|
|
|
draftThemeSlot?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId,
|
|
|
|
|
draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo,
|
|
|
|
@@ -1075,8 +1099,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
|
|
|
|
|
setSavedThemeSlots (completeThemeSlots)
|
|
|
|
|
setDraftThemeSlots (completeThemeSlots)
|
|
|
|
|
setSavedEditingThemeSlot (editingThemeSlot)
|
|
|
|
|
setEditingThemeSlot (editingThemeSlot)
|
|
|
|
|
// Display mode and active slot numbers are browser-local state.
|
|
|
|
|
setClientThemeAppearance ({
|
|
|
|
|
themeMode: draftThemeMode,
|
|
|
|
@@ -1128,10 +1150,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
: settings.theme
|
|
|
|
|
const activeLightThemeSlotNo = getClientActiveLightThemeSlotNo ()
|
|
|
|
|
const activeDarkThemeSlotNo = getClientActiveDarkThemeSlotNo ()
|
|
|
|
|
const initialEditingThemeSlot = resolveDisplayedTheme ({
|
|
|
|
|
themeMode,
|
|
|
|
|
activeLightThemeSlotNo,
|
|
|
|
|
activeDarkThemeSlotNo }, completeThemeSlots).selection
|
|
|
|
|
|
|
|
|
|
setSavedThemeMode (themeMode)
|
|
|
|
|
setDraftThemeMode (themeMode)
|
|
|
|
@@ -1141,8 +1159,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
|
|
|
|
|
setSavedThemeSlots (completeThemeSlots)
|
|
|
|
|
setDraftThemeSlots (completeThemeSlots)
|
|
|
|
|
setSavedEditingThemeSlot (initialEditingThemeSlot)
|
|
|
|
|
setEditingThemeSlot (initialEditingThemeSlot)
|
|
|
|
|
setCachedUserThemeSlots (completeThemeSlots)
|
|
|
|
|
applyThemeAppearanceState ({ themeMode,
|
|
|
|
|
activeLightThemeSlotNo,
|
|
|
|
@@ -1218,6 +1234,16 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
discard: handleThemeDiscard })
|
|
|
|
|
}, [hasUnsavedThemeChanges, savedThemeSlots])
|
|
|
|
|
|
|
|
|
|
useEffect (() => {
|
|
|
|
|
registerUnsavedChangesSource ({
|
|
|
|
|
dirty: hasPageUnsavedChanges,
|
|
|
|
|
discard: discardAllDirtyChanges })
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
registerUnsavedChangesSource (null)
|
|
|
|
|
}
|
|
|
|
|
}, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])
|
|
|
|
|
|
|
|
|
|
useKeyboardShortcuts ({
|
|
|
|
|
['settings.account']: () => {
|
|
|
|
|
requestActiveTab ('account')
|
|
|
|
@@ -1236,7 +1262,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
draftThemeMode,
|
|
|
|
|
draftActiveLightThemeSlotNo,
|
|
|
|
|
draftActiveDarkThemeSlotNo,
|
|
|
|
|
editingThemeSlot,
|
|
|
|
|
currentDisplayedThemeSlot: displayedThemeSelection.selection,
|
|
|
|
|
selectedTheme,
|
|
|
|
|
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection),
|
|
|
|
|
systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト',
|
|
|
|
@@ -1244,36 +1270,27 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
settingsBaseErrors,
|
|
|
|
|
settingsFieldErrors,
|
|
|
|
|
onSelectThemeMode: themeMode => {
|
|
|
|
|
requestThemeStateChange (baseState => ({ ...baseState, themeMode }))
|
|
|
|
|
requestThemeMode (themeMode)
|
|
|
|
|
},
|
|
|
|
|
onSelectLightThemeSlotNo: slotNo => {
|
|
|
|
|
requestThemeStateChange (baseState => ({
|
|
|
|
|
...baseState,
|
|
|
|
|
activeLightThemeSlotNo: slotNo,
|
|
|
|
|
editingThemeSlot: getUserThemeSlotSelection ('light', slotNo) }))
|
|
|
|
|
requestLightThemeSlotNo (slotNo)
|
|
|
|
|
},
|
|
|
|
|
onSelectDarkThemeSlotNo: slotNo => {
|
|
|
|
|
requestThemeStateChange (baseState => ({
|
|
|
|
|
...baseState,
|
|
|
|
|
activeDarkThemeSlotNo: slotNo,
|
|
|
|
|
editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo) }))
|
|
|
|
|
},
|
|
|
|
|
onSelectEditingThemeSlot: selection => {
|
|
|
|
|
requestThemeStateChange (baseState => ({ ...baseState, editingThemeSlot: selection }))
|
|
|
|
|
requestDarkThemeSlotNo (slotNo)
|
|
|
|
|
},
|
|
|
|
|
onThemeTokenChange: (key, value) => {
|
|
|
|
|
updateDraftThemeSlot (editingThemeSlot, tokens => ({ ...tokens, [key]: value }))
|
|
|
|
|
updateDraftThemeSlot (tokens => ({ ...tokens, [key]: value }))
|
|
|
|
|
},
|
|
|
|
|
onTagColourChange: (category, colour) => {
|
|
|
|
|
updateDraftThemeSlot (editingThemeSlot, tokens => ({
|
|
|
|
|
updateDraftThemeSlot (tokens => ({
|
|
|
|
|
...tokens, tagColours: { ...tokens.tagColours, [category]: colour } }))
|
|
|
|
|
},
|
|
|
|
|
onResetThemeSlot: selection => {
|
|
|
|
|
onResetThemeSlot: () => {
|
|
|
|
|
const selection = displayedThemeSelection.selection
|
|
|
|
|
const baseTheme =
|
|
|
|
|
draftThemeSlots[selection]?.baseTheme
|
|
|
|
|
?? selection.split (':')[1] as BuiltinThemeId
|
|
|
|
|
updateDraftThemeSlot (
|
|
|
|
|
selection,
|
|
|
|
|
() => getDefaultUserThemeSlot (
|
|
|
|
|
baseTheme,
|
|
|
|
|
(draftThemeSlots[selection]?.slotNo
|
|
|
|
@@ -1313,7 +1330,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
: ['border-border bg-background text-foreground']),
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
void requestActiveTab (tab.id)
|
|
|
|
|
requestActiveTab (tab.id)
|
|
|
|
|
}}>
|
|
|
|
|
{tab.label}
|
|
|
|
|
{settingsTabErrors[tab.id] ? ' !' : ''}
|
|
|
|
@@ -1367,7 +1384,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
|
|
|
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']),
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
void requestActiveTab (tab.id)
|
|
|
|
|
requestActiveTab (tab.id)
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={handleTabKeyDown}>
|
|
|
|
|
{tab.label}
|
|
|
|
|