このコミットが含まれているのは:
@@ -20,20 +20,20 @@ import { SITE_TITLE } from '@/config'
|
||||
import { apiPut } from '@/lib/api'
|
||||
import {
|
||||
applyClientAppearance,
|
||||
applyThemeSelection,
|
||||
applyThemeAppearanceState,
|
||||
DEFAULT_USER_SETTINGS,
|
||||
deriveUserThemeModeFromSlotSelection,
|
||||
fetchUserSettings,
|
||||
fetchUserThemeSlots,
|
||||
getClientActiveThemeSlot,
|
||||
getClientActiveDarkThemeSlotNo,
|
||||
getClientActiveLightThemeSlotNo,
|
||||
getClientThemeMode,
|
||||
getDefaultUserThemeSlot,
|
||||
hasStoredClientThemeSelection,
|
||||
isUserThemeSlotSelection,
|
||||
normaliseThemeSlotSelectionAgainstSlots,
|
||||
getUserThemeSlotSelection,
|
||||
normaliseUserThemeSlots,
|
||||
resolveDisplayedTheme,
|
||||
setCachedUserThemeSlots,
|
||||
setClientActiveThemeSlot,
|
||||
resolveThemeFromSlotSelection,
|
||||
setClientThemeAppearance,
|
||||
updateUserSettings,
|
||||
upsertUserThemeSlot,
|
||||
USER_THEME_SLOT_SELECTIONS,
|
||||
@@ -47,8 +47,8 @@ import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from
|
||||
import type { Category, Tag, User } from '@/types'
|
||||
import type {
|
||||
BuiltinThemeId,
|
||||
ClientThemeMode,
|
||||
ClientTheme,
|
||||
ThemeSlotSelection,
|
||||
ThemeTokens,
|
||||
ThemeTagColours,
|
||||
UserSettings,
|
||||
@@ -80,12 +80,15 @@ type AccountSectionProps = {
|
||||
userBaseErrors: string[]
|
||||
userFieldErrors: Partial<Record<UserFormField, string[]>> }
|
||||
|
||||
type ThemeModeItem = {
|
||||
id: ClientThemeMode
|
||||
label: string }
|
||||
|
||||
type ThemeSelectionItem = {
|
||||
id: ThemeSlotSelection
|
||||
label: string
|
||||
editable: boolean
|
||||
baseTheme?: BuiltinThemeId
|
||||
slotNo?: UserThemeSlotNo }
|
||||
id: UserThemeSlotSelection
|
||||
label: string
|
||||
baseTheme: BuiltinThemeId
|
||||
slotNo: UserThemeSlotNo }
|
||||
|
||||
type ThemeTokenFieldSpec = {
|
||||
key: ThemeTokenKey
|
||||
@@ -96,17 +99,20 @@ type ThemeTokenGroupSpec = {
|
||||
fields: ThemeTokenFieldSpec[] }
|
||||
|
||||
type ThemeSectionProps = {
|
||||
draftActiveThemeSlot: ThemeSlotSelection
|
||||
draftThemeSlots: UserThemeSlotMap
|
||||
editingThemeSlot: UserThemeSlotSelection | null
|
||||
draftThemeMode: ClientThemeMode
|
||||
draftActiveLightThemeSlotNo: UserThemeSlotNo
|
||||
draftActiveDarkThemeSlotNo: UserThemeSlotNo
|
||||
editingThemeSlot: UserThemeSlotSelection
|
||||
selectedTheme: ClientTheme
|
||||
selectedThemeLabel: string
|
||||
displayedThemeLabel: string
|
||||
systemThemeLabel: string
|
||||
hasUnsavedChanges: boolean
|
||||
settingsBaseErrors: string[]
|
||||
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
|
||||
onSelectThemeSlot: (selection: ThemeSlotSelection) => void
|
||||
onEditThemeSlot: (selection: UserThemeSlotSelection) => void
|
||||
onEnsureEditableTheme: () => void
|
||||
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
|
||||
@@ -117,25 +123,34 @@ type SettingsTabDirtyState = {
|
||||
dirty: boolean
|
||||
discard: () => void | Promise<void> }
|
||||
|
||||
type ThemeDraftState = {
|
||||
themeMode: ClientThemeMode
|
||||
activeLightThemeSlotNo: UserThemeSlotNo
|
||||
activeDarkThemeSlotNo: UserThemeSlotNo
|
||||
themeSlots: UserThemeSlotMap
|
||||
editingThemeSlot: UserThemeSlotSelection }
|
||||
|
||||
const tabs: TabSpec[] = [
|
||||
{ id: 'account', label: 'アカウント' },
|
||||
{ id: 'behavior', label: '動作' },
|
||||
{ id: 'theme', label: 'テーマ' },
|
||||
{ id: 'keyboard', label: 'キーボード' } ]
|
||||
|
||||
const themeModeSelections: ThemeModeItem[] = [
|
||||
{ id: 'system', label: 'システム設定に従う' },
|
||||
{ id: 'light', label: 'ライト' },
|
||||
{ id: 'dark', label: 'ダーク' } ]
|
||||
|
||||
const sectionClassName =
|
||||
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
||||
|
||||
const themeSelections: ThemeSelectionItem[] = [
|
||||
{ id: 'system', label: 'システム設定', editable: false },
|
||||
{ id: 'builtin:light', label: '標準ライト', editable: false },
|
||||
{ id: 'builtin:dark', label: '標準ダーク', editable: false },
|
||||
{ id: 'user:light:1', label: 'ライト 1', editable: true, baseTheme: 'light', slotNo: 1 },
|
||||
{ id: 'user:light:2', label: 'ライト 2', editable: true, baseTheme: 'light', slotNo: 2 },
|
||||
{ id: 'user:light:3', label: 'ライト 3', editable: true, baseTheme: 'light', slotNo: 3 },
|
||||
{ id: 'user:dark:1', label: 'ダーク 1', editable: true, baseTheme: 'dark', slotNo: 1 },
|
||||
{ id: 'user:dark:2', label: 'ダーク 2', editable: true, baseTheme: 'dark', slotNo: 2 },
|
||||
{ id: 'user:dark:3', label: 'ダーク 3', editable: true, baseTheme: 'dark', slotNo: 3 },
|
||||
{ id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 },
|
||||
{ id: 'user:light:2', label: 'ライト 2', baseTheme: 'light', slotNo: 2 },
|
||||
{ id: 'user:light:3', label: 'ライト 3', baseTheme: 'light', slotNo: 3 },
|
||||
{ id: 'user:dark:1', label: 'ダーク 1', baseTheme: 'dark', slotNo: 1 },
|
||||
{ id: 'user:dark:2', label: 'ダーク 2', baseTheme: 'dark', slotNo: 2 },
|
||||
{ id: 'user:dark:3', label: 'ダーク 3', baseTheme: 'dark', slotNo: 3 },
|
||||
]
|
||||
|
||||
const themeTokenGroups: ThemeTokenGroupSpec[] = [
|
||||
@@ -159,12 +174,13 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
|
||||
] },
|
||||
{ title: '上部ナビ',
|
||||
fields: [
|
||||
{ key: 'topNavRootBackgroundMobile', label: '通常背景' },
|
||||
{ key: 'topNavRootBackgroundMobile', label: 'スマホ背景' },
|
||||
{ key: 'topNavRootBackgroundDesktop', label: 'PC 背景' },
|
||||
{ key: 'topNavHighlightBackground', label: '選択中背景' },
|
||||
{ key: 'topNavActiveBackground', label: '選択中背景' },
|
||||
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
|
||||
{ key: 'topNavMobileActiveBackground', label: 'スマホ選択中背景' },
|
||||
{ key: 'topNavLink', label: 'リンク' },
|
||||
{ key: 'topNavBrandLink', label: 'ロゴ色' },
|
||||
{ key: 'topNavMenuLink', label: 'メニュー文字色' },
|
||||
] },
|
||||
]
|
||||
|
||||
@@ -215,8 +231,8 @@ const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
|
||||
[`--tag-colour-${ category }`, tagColours[category]],
|
||||
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
|
||||
|
||||
const themeLabelBySelection = (selection: ThemeSlotSelection): string =>
|
||||
themeSelections.find (item => item.id === selection)?.label ?? 'システム設定'
|
||||
const themeLabelBySelection = (selection: UserThemeSlotSelection): string =>
|
||||
themeSelections.find (item => item.id === selection)?.label ?? 'ライト 1'
|
||||
|
||||
const ensureCompleteThemeSlots = (
|
||||
themeSlots: UserThemeSlotMap,
|
||||
@@ -236,31 +252,21 @@ const ensureCompleteThemeSlots = (
|
||||
)
|
||||
|
||||
const serialiseThemeDraft = (
|
||||
activeThemeSlot: ThemeSlotSelection,
|
||||
themeSlots: UserThemeSlotMap,
|
||||
themeMode: ClientThemeMode,
|
||||
activeLightThemeSlotNo: UserThemeSlotNo,
|
||||
activeDarkThemeSlotNo: UserThemeSlotNo,
|
||||
themeSlots: UserThemeSlotMap,
|
||||
): string =>
|
||||
JSON.stringify ({
|
||||
activeThemeSlot,
|
||||
themeMode,
|
||||
activeLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo,
|
||||
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
|
||||
selection,
|
||||
tokens: themeSlots[selection]?.tokens,
|
||||
})),
|
||||
})
|
||||
|
||||
const themeToSelection = (
|
||||
theme: UserSettings['theme'],
|
||||
): ThemeSlotSelection =>
|
||||
theme === 'system'
|
||||
? 'system'
|
||||
: `builtin:${ theme }`
|
||||
|
||||
const themeRowBaseLabel = (
|
||||
selection: ThemeSlotSelection,
|
||||
): string =>
|
||||
selection === 'system'
|
||||
? '端末設定に従います'
|
||||
: themeLabelBySelection (selection)
|
||||
|
||||
|
||||
const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean =>
|
||||
key.startsWith ('topNav')
|
||||
@@ -447,32 +453,27 @@ const AccountSection: FC<AccountSectionProps> = (
|
||||
</section>)
|
||||
|
||||
const ThemeSection: FC<ThemeSectionProps> = (
|
||||
{ draftActiveThemeSlot,
|
||||
draftThemeSlots,
|
||||
{ draftThemeMode,
|
||||
draftActiveLightThemeSlotNo,
|
||||
draftActiveDarkThemeSlotNo,
|
||||
editingThemeSlot,
|
||||
selectedTheme,
|
||||
selectedThemeLabel,
|
||||
displayedThemeLabel,
|
||||
systemThemeLabel,
|
||||
hasUnsavedChanges,
|
||||
settingsBaseErrors,
|
||||
settingsFieldErrors,
|
||||
onSelectThemeSlot,
|
||||
onEditThemeSlot,
|
||||
onEnsureEditableTheme,
|
||||
onSelectThemeMode,
|
||||
onSelectLightThemeSlotNo,
|
||||
onSelectDarkThemeSlotNo,
|
||||
onSelectEditingThemeSlot,
|
||||
onThemeTokenChange,
|
||||
onTagColourChange,
|
||||
onResetThemeSlot,
|
||||
onThemeSubmit,
|
||||
onThemeDiscard },
|
||||
) => {
|
||||
const currentSelection =
|
||||
themeSelections.find (item => item.id === draftActiveThemeSlot)
|
||||
?? themeSelections[0]
|
||||
const currentEditableSelection =
|
||||
editingThemeSlot
|
||||
?? (isUserThemeSlotSelection (draftActiveThemeSlot)
|
||||
? draftActiveThemeSlot
|
||||
: null)
|
||||
const canEditTheme = currentEditableSelection != null
|
||||
const currentEditableSelection = editingThemeSlot
|
||||
|
||||
return (
|
||||
<section className={sectionClassName}>
|
||||
@@ -480,12 +481,15 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-bold">テーマ</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
現在の表示: {selectedThemeLabel}
|
||||
現在表示中: {displayedThemeLabel}
|
||||
</p>
|
||||
{editingThemeSlot && (
|
||||
{draftThemeMode === 'system' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
編輯対象: {themeLabelBySelection (editingThemeSlot)}
|
||||
システム設定: {systemThemeLabel}
|
||||
</p>)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
編輯対象: {themeLabelBySelection (currentEditableSelection)}
|
||||
</p>
|
||||
{hasUnsavedChanges && (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
未保存の変更があります
|
||||
@@ -514,13 +518,60 @@ 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>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Label>表示モード</Label>
|
||||
<select
|
||||
className={inputClass (false)}
|
||||
value={draftThemeMode}
|
||||
onChange={ev =>
|
||||
onSelectThemeMode (ev.target.value as ClientThemeMode)}>
|
||||
{themeModeSelections.map (selection => (
|
||||
<option key={selection.id} value={selection.id}>
|
||||
{selection.label}
|
||||
</option>))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>ライト用テーマ</Label>
|
||||
<select
|
||||
className={inputClass (false)}
|
||||
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>))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>ダーク用テーマ</Label>
|
||||
<select
|
||||
className={inputClass (false)}
|
||||
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>))}
|
||||
</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={draftActiveThemeSlot}
|
||||
value={currentEditableSelection}
|
||||
onChange={ev =>
|
||||
onSelectThemeSlot (ev.target.value as ThemeSlotSelection)}>
|
||||
onSelectEditingThemeSlot (ev.target.value as UserThemeSlotSelection)}>
|
||||
{themeSelections.map (selection => (
|
||||
<option key={selection.id} value={selection.id}>
|
||||
{selection.label}
|
||||
@@ -529,43 +580,15 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (isUserThemeSlotSelection (draftActiveThemeSlot))
|
||||
{
|
||||
onEditThemeSlot (draftActiveThemeSlot)
|
||||
return
|
||||
}
|
||||
|
||||
onEnsureEditableTheme ()
|
||||
}}>
|
||||
{canEditTheme ? 'この枠を編輯' : '編輯する枠を選ぶ'}
|
||||
variant="ghost"
|
||||
onClick={() => onResetThemeSlot (currentEditableSelection)}>
|
||||
既定に戻す
|
||||
</Button>
|
||||
|
||||
{currentEditableSelection && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onResetThemeSlot (currentEditableSelection)}>
|
||||
既定に戻す
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold">{currentSelection.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{themeRowBaseLabel (currentSelection.id)}
|
||||
</p>
|
||||
{canEditTheme
|
||||
? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
この枠への変更は保存時に反映されます。
|
||||
</p>)
|
||||
: (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
標準テーマは直接編輯できません。編輯する枠を選んでください。
|
||||
</p>)}
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>変更は保存時に反映されます。</p>
|
||||
</div>
|
||||
|
||||
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
|
||||
@@ -593,10 +616,9 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
<p className="text-sm font-medium">{field.label}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
<input
|
||||
type="color"
|
||||
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
|
||||
disabled={!(canEditTheme)}
|
||||
onChange={ev =>
|
||||
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
|
||||
className="h-10 w-16 cursor-pointer rounded border border-border
|
||||
@@ -635,7 +657,6 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
||||
<input
|
||||
type="color"
|
||||
value={selectedTheme.tokens.tagColours[category]}
|
||||
disabled={!(canEditTheme)}
|
||||
onChange={ev => onTagColourChange (category, ev.target.value)}
|
||||
className="h-10 w-16 cursor-pointer rounded border border-border
|
||||
bg-transparent p-1 disabled:cursor-not-allowed
|
||||
@@ -659,16 +680,26 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
const [name, setName] = useState ('')
|
||||
const [settingsError, setSettingsError] = useState<string | null> (null)
|
||||
const [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
|
||||
const [savedActiveThemeSlot, setSavedActiveThemeSlot] =
|
||||
useState<ThemeSlotSelection> (getClientActiveThemeSlot ())
|
||||
const [draftActiveThemeSlot, setDraftActiveThemeSlot] =
|
||||
useState<ThemeSlotSelection> (getClientActiveThemeSlot ())
|
||||
const [savedThemeMode, setSavedThemeMode] =
|
||||
useState<ClientThemeMode> (getClientThemeMode ())
|
||||
const [draftThemeMode, setDraftThemeMode] =
|
||||
useState<ClientThemeMode> (getClientThemeMode ())
|
||||
const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] =
|
||||
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
|
||||
const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] =
|
||||
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
|
||||
const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] =
|
||||
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
|
||||
const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] =
|
||||
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
|
||||
const [savedThemeSlots, setSavedThemeSlots] =
|
||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
||||
const [draftThemeSlots, setDraftThemeSlots] =
|
||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
||||
const [savedEditingThemeSlot, setSavedEditingThemeSlot] =
|
||||
useState<UserThemeSlotSelection> ('user:light:1')
|
||||
const [editingThemeSlot, setEditingThemeSlot] =
|
||||
useState<UserThemeSlotSelection | null> (null)
|
||||
useState<UserThemeSlotSelection> ('user:light:1')
|
||||
const [dirtyStates, setDirtyStates] =
|
||||
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
|
||||
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
||||
@@ -684,25 +715,59 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
useValidationErrors<SettingsFormField> ()
|
||||
|
||||
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
|
||||
const displayedThemeSelection = useMemo (
|
||||
() => resolveDisplayedTheme ({
|
||||
themeMode: draftThemeMode,
|
||||
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
|
||||
}, draftThemeSlots),
|
||||
[draftActiveDarkThemeSlotNo,
|
||||
draftActiveLightThemeSlotNo,
|
||||
draftThemeMode,
|
||||
draftThemeSlots],
|
||||
)
|
||||
const selectedTheme = useMemo<ClientTheme> (
|
||||
() => {
|
||||
const resolvedTheme =
|
||||
resolveThemeFromSlotSelection (draftActiveThemeSlot, draftThemeSlots)
|
||||
const currentSelection =
|
||||
themeSelections.find (selection => selection.id === editingThemeSlot)
|
||||
?? themeSelections[0]
|
||||
const themeSlot =
|
||||
draftThemeSlots[currentSelection.id]
|
||||
?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo)
|
||||
return {
|
||||
id: resolvedTheme.baseTheme,
|
||||
name: themeLabelBySelection (draftActiveThemeSlot),
|
||||
builtin: resolvedTheme.builtin,
|
||||
baseTheme: resolvedTheme.baseTheme,
|
||||
tokens: resolvedTheme.tokens,
|
||||
id: currentSelection.baseTheme,
|
||||
name: themeLabelBySelection (currentSelection.id),
|
||||
builtin: false,
|
||||
baseTheme: currentSelection.baseTheme,
|
||||
tokens: themeSlot.tokens,
|
||||
}
|
||||
},
|
||||
[draftActiveThemeSlot, draftThemeSlots],
|
||||
[draftThemeSlots, editingThemeSlot],
|
||||
)
|
||||
const hasUnsavedThemeChanges = useMemo (
|
||||
() =>
|
||||
serialiseThemeDraft (draftActiveThemeSlot, draftThemeSlots)
|
||||
!== serialiseThemeDraft (savedActiveThemeSlot, savedThemeSlots),
|
||||
[draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots],
|
||||
serialiseThemeDraft (
|
||||
draftThemeMode,
|
||||
draftActiveLightThemeSlotNo,
|
||||
draftActiveDarkThemeSlotNo,
|
||||
draftThemeSlots,
|
||||
)
|
||||
!== serialiseThemeDraft (
|
||||
savedThemeMode,
|
||||
savedActiveLightThemeSlotNo,
|
||||
savedActiveDarkThemeSlotNo,
|
||||
savedThemeSlots,
|
||||
),
|
||||
[draftActiveDarkThemeSlotNo,
|
||||
draftActiveLightThemeSlotNo,
|
||||
draftThemeMode,
|
||||
draftThemeSlots,
|
||||
editingThemeSlot,
|
||||
savedActiveDarkThemeSlotNo,
|
||||
savedActiveLightThemeSlotNo,
|
||||
savedEditingThemeSlot,
|
||||
savedThemeMode,
|
||||
savedThemeSlots],
|
||||
)
|
||||
|
||||
const applyActiveTab = (tab: SettingsTab) => {
|
||||
@@ -753,25 +818,63 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name],
|
||||
)
|
||||
|
||||
const applyDraftThemeSelection = (
|
||||
activeThemeSlot: ThemeSlotSelection,
|
||||
themeSlots: UserThemeSlotMap,
|
||||
const applyDraftThemeState = (
|
||||
{ themeMode = draftThemeMode,
|
||||
activeLightThemeSlotNo = draftActiveLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo,
|
||||
themeSlots = draftThemeSlots,
|
||||
nextEditingThemeSlot = editingThemeSlot }: {
|
||||
themeMode?: ClientThemeMode
|
||||
activeLightThemeSlotNo?: UserThemeSlotNo
|
||||
activeDarkThemeSlotNo?: UserThemeSlotNo
|
||||
themeSlots?: UserThemeSlotMap
|
||||
nextEditingThemeSlot?: UserThemeSlotSelection },
|
||||
) => {
|
||||
setDraftActiveThemeSlot (activeThemeSlot)
|
||||
setDraftThemeMode (themeMode)
|
||||
setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
|
||||
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
|
||||
setDraftThemeSlots (themeSlots)
|
||||
setEditingThemeSlot (nextEditingThemeSlot)
|
||||
setDraftSettings (current => ({
|
||||
...current,
|
||||
theme: deriveUserThemeModeFromSlotSelection (activeThemeSlot),
|
||||
theme: themeMode,
|
||||
}))
|
||||
applyThemeSelection (activeThemeSlot, themeSlots)
|
||||
setCachedUserThemeSlots (themeSlots)
|
||||
applyThemeAppearanceState ({
|
||||
themeMode,
|
||||
activeLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo,
|
||||
}, themeSlots)
|
||||
}
|
||||
|
||||
const currentThemeDraftState = (): ThemeDraftState => ({
|
||||
themeMode: draftThemeMode,
|
||||
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
|
||||
themeSlots: draftThemeSlots,
|
||||
editingThemeSlot,
|
||||
})
|
||||
|
||||
const savedThemeDraftState = (): ThemeDraftState => ({
|
||||
themeMode: savedThemeMode,
|
||||
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
|
||||
themeSlots: savedThemeSlots,
|
||||
editingThemeSlot: savedEditingThemeSlot,
|
||||
})
|
||||
|
||||
const discardThemeChanges = () => {
|
||||
setDraftActiveThemeSlot (savedActiveThemeSlot)
|
||||
setDraftThemeMode (savedThemeMode)
|
||||
setDraftActiveLightThemeSlotNo (savedActiveLightThemeSlotNo)
|
||||
setDraftActiveDarkThemeSlotNo (savedActiveDarkThemeSlotNo)
|
||||
setDraftThemeSlots (savedThemeSlots)
|
||||
setEditingThemeSlot (null)
|
||||
setEditingThemeSlot (savedEditingThemeSlot)
|
||||
setCachedUserThemeSlots (savedThemeSlots)
|
||||
setClientActiveThemeSlot (savedActiveThemeSlot)
|
||||
setClientThemeAppearance ({
|
||||
themeMode: savedThemeMode,
|
||||
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
|
||||
})
|
||||
applyClientAppearance (savedThemeSlots)
|
||||
}
|
||||
|
||||
@@ -800,66 +903,42 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
applyActiveTab (nextTab)
|
||||
}
|
||||
|
||||
const requestThemeSlotSelection = async (
|
||||
selection: ThemeSlotSelection,
|
||||
editingSlot: UserThemeSlotSelection | null,
|
||||
const requestThemeStateChange = async (
|
||||
buildNextState: (baseState: ThemeDraftState) => ThemeDraftState,
|
||||
description = 'このまま切り替えると、保存していない変更は失われます。',
|
||||
cancelText = 'このまま編集する',
|
||||
confirmText = '変更を破棄して切り替える',
|
||||
): Promise<void> => {
|
||||
if (
|
||||
selection === draftActiveThemeSlot
|
||||
&& editingSlot === editingThemeSlot
|
||||
)
|
||||
return
|
||||
const baseState =
|
||||
hasUnsavedThemeChanges
|
||||
? savedThemeDraftState ()
|
||||
: currentThemeDraftState ()
|
||||
|
||||
if (hasUnsavedThemeChanges)
|
||||
{
|
||||
const confirmed = await confirmDiscardChanges (
|
||||
'このまま切り替えると、保存していない変更は失われます。',
|
||||
'この枠に残る',
|
||||
'変更を破棄して切り替える',
|
||||
description,
|
||||
cancelText,
|
||||
confirmText,
|
||||
)
|
||||
if (!(confirmed))
|
||||
return
|
||||
|
||||
discardThemeChanges ()
|
||||
}
|
||||
|
||||
setEditingThemeSlot (editingSlot)
|
||||
applyDraftThemeSelection (selection, savedThemeSlots)
|
||||
}
|
||||
const nextState = buildNextState (baseState)
|
||||
|
||||
const ensureEditableThemeSlot = async (): Promise<UserThemeSlotSelection | null> => {
|
||||
if (editingThemeSlot != null)
|
||||
return editingThemeSlot
|
||||
if (isUserThemeSlotSelection (draftActiveThemeSlot))
|
||||
{
|
||||
setEditingThemeSlot (draftActiveThemeSlot)
|
||||
return draftActiveThemeSlot
|
||||
}
|
||||
|
||||
const choice = await dialogue.choice<UserThemeSlotSelection> ({
|
||||
title: '保存先を選択してください',
|
||||
description: '色を変更する前に、保存先のテーマ枠を選択してください。',
|
||||
cancelText: '取消',
|
||||
choices: themeSelections
|
||||
.filter (selection => selection.editable)
|
||||
.map (selection => ({
|
||||
value: selection.id as UserThemeSlotSelection,
|
||||
label: selection.label,
|
||||
})),
|
||||
applyDraftThemeState ({
|
||||
themeMode: nextState.themeMode,
|
||||
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
|
||||
themeSlots: nextState.themeSlots,
|
||||
nextEditingThemeSlot: nextState.editingThemeSlot,
|
||||
})
|
||||
|
||||
if (choice == null)
|
||||
return null
|
||||
|
||||
setEditingThemeSlot (choice)
|
||||
applyDraftThemeSelection (choice, savedThemeSlots)
|
||||
return choice
|
||||
}
|
||||
|
||||
const updateDraftThemeSlot = (
|
||||
selection: UserThemeSlotSelection,
|
||||
updater: (tokens: ThemeTokens) => ThemeTokens,
|
||||
options?: { activeThemeSlot?: ThemeSlotSelection },
|
||||
) => {
|
||||
const currentThemeSlot =
|
||||
draftThemeSlots[selection]
|
||||
@@ -872,10 +951,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
},
|
||||
}
|
||||
|
||||
applyDraftThemeSelection (
|
||||
options?.activeThemeSlot ?? draftActiveThemeSlot,
|
||||
nextThemeSlots,
|
||||
)
|
||||
applyDraftThemeState ({
|
||||
themeSlots: nextThemeSlots,
|
||||
nextEditingThemeSlot: selection,
|
||||
})
|
||||
}
|
||||
|
||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
@@ -944,8 +1023,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
const draftThemeSlot = draftThemeSlots[selection]
|
||||
const savedThemeSlot = savedThemeSlots[selection]
|
||||
const requiresPersist =
|
||||
selection === draftActiveThemeSlot
|
||||
&& isUserThemeSlotSelection (draftActiveThemeSlot)
|
||||
selection === editingThemeSlot
|
||||
&& savedThemeSlot?.createdAt == null
|
||||
|
||||
if (
|
||||
@@ -969,21 +1047,22 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const themeMode =
|
||||
deriveUserThemeModeFromSlotSelection (draftActiveThemeSlot)
|
||||
const data = await updateUserSettings ({ theme: themeMode })
|
||||
const data = await updateUserSettings ({ theme: draftThemeMode })
|
||||
const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)
|
||||
|
||||
setDraftSettings ({ ...data, theme: themeMode })
|
||||
setSavedActiveThemeSlot (draftActiveThemeSlot)
|
||||
setDraftSettings ({ ...data, theme: draftThemeMode })
|
||||
setSavedThemeMode (draftThemeMode)
|
||||
setSavedActiveLightThemeSlotNo (draftActiveLightThemeSlotNo)
|
||||
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
|
||||
setSavedThemeSlots (completeThemeSlots)
|
||||
setDraftThemeSlots (completeThemeSlots)
|
||||
setEditingThemeSlot (
|
||||
isUserThemeSlotSelection (draftActiveThemeSlot)
|
||||
? draftActiveThemeSlot
|
||||
: null,
|
||||
)
|
||||
setClientActiveThemeSlot (draftActiveThemeSlot)
|
||||
setSavedEditingThemeSlot (editingThemeSlot)
|
||||
setEditingThemeSlot (editingThemeSlot)
|
||||
setClientThemeAppearance ({
|
||||
themeMode: draftThemeMode,
|
||||
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
|
||||
})
|
||||
setCachedUserThemeSlots (completeThemeSlots)
|
||||
applyClientAppearance (completeThemeSlots)
|
||||
toast ({ title: 'テーマ設定を保存しました.' })
|
||||
@@ -1026,31 +1105,35 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
|
||||
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
|
||||
const completeThemeSlots = ensureCompleteThemeSlots (storedThemeSlots)
|
||||
const initialSelection = (
|
||||
const themeMode =
|
||||
hasStoredClientThemeSelection ()
|
||||
? getClientActiveThemeSlot ()
|
||||
: themeToSelection (settings.theme)
|
||||
)
|
||||
const nextSelection = normaliseThemeSlotSelectionAgainstSlots (
|
||||
initialSelection,
|
||||
storedThemeSlots,
|
||||
)
|
||||
const themeMode = deriveUserThemeModeFromSlotSelection (nextSelection)
|
||||
? getClientThemeMode ()
|
||||
: settings.theme
|
||||
const activeLightThemeSlotNo = getClientActiveLightThemeSlotNo ()
|
||||
const activeDarkThemeSlotNo = getClientActiveDarkThemeSlotNo ()
|
||||
const initialEditingThemeSlot = resolveDisplayedTheme ({
|
||||
themeMode,
|
||||
activeLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo,
|
||||
}, completeThemeSlots).selection
|
||||
|
||||
setDraftSettings ({ ...settings, theme: themeMode })
|
||||
setSavedActiveThemeSlot (nextSelection)
|
||||
setDraftActiveThemeSlot (nextSelection)
|
||||
setSavedThemeMode (themeMode)
|
||||
setDraftThemeMode (themeMode)
|
||||
setSavedActiveLightThemeSlotNo (activeLightThemeSlotNo)
|
||||
setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
|
||||
setSavedActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
|
||||
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
|
||||
setSavedThemeSlots (completeThemeSlots)
|
||||
setDraftThemeSlots (completeThemeSlots)
|
||||
setEditingThemeSlot (
|
||||
isUserThemeSlotSelection (nextSelection)
|
||||
? nextSelection
|
||||
: null,
|
||||
)
|
||||
setSavedEditingThemeSlot (initialEditingThemeSlot)
|
||||
setEditingThemeSlot (initialEditingThemeSlot)
|
||||
setCachedUserThemeSlots (completeThemeSlots)
|
||||
applyThemeSelection (nextSelection, completeThemeSlots)
|
||||
if (nextSelection !== initialSelection)
|
||||
setClientActiveThemeSlot (nextSelection)
|
||||
applyThemeAppearanceState ({
|
||||
themeMode,
|
||||
activeLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo,
|
||||
}, completeThemeSlots)
|
||||
setSettingsError (null)
|
||||
}
|
||||
catch
|
||||
@@ -1083,12 +1166,21 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}, [location.pathname, location.search, settingsFieldErrors.theme])
|
||||
|
||||
useEffect (() => {
|
||||
applyThemeSelection (draftActiveThemeSlot, draftThemeSlots)
|
||||
applyThemeAppearanceState ({
|
||||
themeMode: draftThemeMode,
|
||||
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
||||
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
|
||||
}, draftThemeSlots)
|
||||
|
||||
return () => {
|
||||
applyThemeSelection (savedActiveThemeSlot, savedThemeSlots)
|
||||
setCachedUserThemeSlots (savedThemeSlots)
|
||||
applyClientAppearance (savedThemeSlots)
|
||||
}
|
||||
}, [draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots])
|
||||
}, [draftActiveDarkThemeSlotNo,
|
||||
draftActiveLightThemeSlotNo,
|
||||
draftThemeMode,
|
||||
draftThemeSlots,
|
||||
savedThemeSlots])
|
||||
|
||||
const accountSectionProps: AccountSectionProps | null =
|
||||
user
|
||||
@@ -1109,7 +1201,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
dirty: hasUnsavedThemeChanges,
|
||||
discard: handleThemeDiscard,
|
||||
})
|
||||
}, [hasUnsavedThemeChanges, savedActiveThemeSlot, savedThemeSlots])
|
||||
}, [hasUnsavedThemeChanges, savedThemeSlots])
|
||||
|
||||
useKeyboardShortcuts ({
|
||||
'settings.account': () => {
|
||||
@@ -1127,49 +1219,56 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
})
|
||||
|
||||
const themeSectionProps: ThemeSectionProps = {
|
||||
draftActiveThemeSlot,
|
||||
draftThemeSlots,
|
||||
draftThemeMode,
|
||||
draftActiveLightThemeSlotNo,
|
||||
draftActiveDarkThemeSlotNo,
|
||||
editingThemeSlot,
|
||||
selectedTheme,
|
||||
selectedThemeLabel: themeLabelBySelection (draftActiveThemeSlot),
|
||||
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection),
|
||||
systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト',
|
||||
hasUnsavedChanges: hasUnsavedThemeChanges,
|
||||
settingsBaseErrors,
|
||||
settingsFieldErrors,
|
||||
onSelectThemeSlot: selection => {
|
||||
void requestThemeSlotSelection (selection, null)
|
||||
onSelectThemeMode: themeMode => {
|
||||
void requestThemeStateChange (baseState => ({
|
||||
...baseState,
|
||||
themeMode,
|
||||
}))
|
||||
},
|
||||
onEditThemeSlot: selection => {
|
||||
void requestThemeSlotSelection (selection, selection)
|
||||
onSelectLightThemeSlotNo: slotNo => {
|
||||
void requestThemeStateChange (baseState => ({
|
||||
...baseState,
|
||||
activeLightThemeSlotNo: slotNo,
|
||||
editingThemeSlot: getUserThemeSlotSelection ('light', slotNo),
|
||||
}))
|
||||
},
|
||||
onEnsureEditableTheme: () => {
|
||||
void ensureEditableThemeSlot ()
|
||||
onSelectDarkThemeSlotNo: slotNo => {
|
||||
void requestThemeStateChange (baseState => ({
|
||||
...baseState,
|
||||
activeDarkThemeSlotNo: slotNo,
|
||||
editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo),
|
||||
}))
|
||||
},
|
||||
onSelectEditingThemeSlot: selection => {
|
||||
void requestThemeStateChange (baseState => ({
|
||||
...baseState,
|
||||
editingThemeSlot: selection,
|
||||
}))
|
||||
},
|
||||
onThemeTokenChange: (key, value) => {
|
||||
void (async () => {
|
||||
const editableThemeSlot = await ensureEditableThemeSlot ()
|
||||
if (editableThemeSlot == null)
|
||||
return
|
||||
|
||||
updateDraftThemeSlot (editableThemeSlot, tokens => ({
|
||||
...tokens,
|
||||
[key]: value,
|
||||
}), { activeThemeSlot: editableThemeSlot })
|
||||
}) ()
|
||||
updateDraftThemeSlot (editingThemeSlot, tokens => ({
|
||||
...tokens,
|
||||
[key]: value,
|
||||
}))
|
||||
},
|
||||
onTagColourChange: (category, colour) => {
|
||||
void (async () => {
|
||||
const editableThemeSlot = await ensureEditableThemeSlot ()
|
||||
if (editableThemeSlot == null)
|
||||
return
|
||||
|
||||
updateDraftThemeSlot (editableThemeSlot, tokens => ({
|
||||
...tokens,
|
||||
tagColours: {
|
||||
...tokens.tagColours,
|
||||
[category]: colour,
|
||||
},
|
||||
}), { activeThemeSlot: editableThemeSlot })
|
||||
}) ()
|
||||
updateDraftThemeSlot (editingThemeSlot, tokens => ({
|
||||
...tokens,
|
||||
tagColours: {
|
||||
...tokens.tagColours,
|
||||
[category]: colour,
|
||||
},
|
||||
}))
|
||||
},
|
||||
onResetThemeSlot: selection => {
|
||||
const baseTheme =
|
||||
|
||||
新しい課題から参照
ユーザをブロックする