設定画面 (#34) #397

マージ済み
みてるぞ が 31 個のコミットを feature/034 から main へマージ 2026-07-07 00:48:41 +09:00
3個のファイルの変更329行の追加192行の削除
コミット f66532a55d の変更だけを表示してゐます - すべてのコミットを表示
+3 -1
ファイルの表示
@@ -38,6 +38,7 @@
--top-nav-root-bg-desktop: #fefce8; --top-nav-root-bg-desktop: #fefce8;
--top-nav-active-bg: #fef08a; --top-nav-active-bg: #fef08a;
--top-nav-submenu-bg: #fef08a; --top-nav-submenu-bg: #fef08a;
--top-nav-mobile-menu-bg: #fef08a;
--top-nav-mobile-active-bg: #fefce8; --top-nav-mobile-active-bg: #fefce8;
--top-nav-brand-link: #db2777; --top-nav-brand-link: #db2777;
--top-nav-menu-link: #1d4ed8; --top-nav-menu-link: #1d4ed8;
@@ -89,6 +90,7 @@
--top-nav-root-bg-desktop: #230505; --top-nav-root-bg-desktop: #230505;
--top-nav-active-bg: #450a0a; --top-nav-active-bg: #450a0a;
--top-nav-submenu-bg: #450a0a; --top-nav-submenu-bg: #450a0a;
--top-nav-mobile-menu-bg: #230505;
--top-nav-mobile-active-bg: #450a0a; --top-nav-mobile-active-bg: #450a0a;
--top-nav-brand-link: #f9a8d4; --top-nav-brand-link: #f9a8d4;
--top-nav-menu-link: #93c5fd; --top-nav-menu-link: #93c5fd;
@@ -237,7 +239,7 @@ body
.top-nav-mobile-menu .top-nav-mobile-menu
{ {
background: var(--top-nav-root-bg-mobile); background: var(--top-nav-mobile-menu-bg);
} }
.top-nav-mobile-active .top-nav-mobile-active
+201 -78
ファイルの表示
@@ -52,6 +52,20 @@ export type ThemeTokens = {
topNavBrandLink: string topNavBrandLink: string
topNavMenuLink: string topNavMenuLink: string
tagColours: ThemeTagColours } tagColours: ThemeTagColours }
type EditableTopNavTokens = {
rootBackgroundMobile: string
rootBackgroundDesktop: string
activeBackground: string
brandLink: string }
type ResolvedTopNavTokens = {
rootBackgroundMobile: string
rootBackgroundDesktop: string
activeBackground: string
submenuBackground: string
mobileMenuBackground: string
mobileActiveBackground: string
brandLink: string
menuLink: string }
export type UserThemeSlot = { export type UserThemeSlot = {
baseTheme: BuiltinThemeId baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo slotNo: UserThemeSlotNo
@@ -908,9 +922,100 @@ const normaliseTopNavColourValue = (
} }
const normaliseThemeTokens = ( const clamp = (value: number, min: number, max: number): number =>
rawTokens: unknown, Math.min (Math.max (value, min), max)
baseTheme: BuiltinThemeId,
const parseHslComponents = (
value: string,
): { h: number
s: number
l: number } | null => {
const raw = value.startsWith ('hsl(') && value.endsWith (')')
? value.slice (4, -1)
: value
const match =
raw.match (
/^(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/,
)
if (match == null)
return null
return {
h: Number (match[1]),
s: Number (match[2]),
l: Number (match[3]),
}
}
const formatHslToken = (
{ h, s, l }: { h: number
s: number
l: number },
): string =>
`${ h.toFixed (1).replace (/\.0$/, '') }`
+ ` ${ s.toFixed (1).replace (/\.0$/, '') }%`
+ ` ${ l.toFixed (1).replace (/\.0$/, '') }%`
const hslLightness = (value: string): number | null =>
parseHslComponents (value)?.l ?? null
const readableForegroundFor = (value: string): string =>
(hslLightness (value) ?? 50) < 56
? LIGHT_THEME_TOKENS.primaryForeground
: DARK_THEME_TOKENS.primaryForeground
const deriveMutedForeground = (
foreground: string,
background: string,
fallback: string,
): string => {
const foregroundHsl = parseHslComponents (foreground)
const backgroundHsl = parseHslComponents (background)
if (foregroundHsl == null || backgroundHsl == null)
return fallback
return formatHslToken ({
h: foregroundHsl.h,
s: clamp (foregroundHsl.s * .6, 0, 100),
l: clamp (
foregroundHsl.l + (backgroundHsl.l - foregroundHsl.l) * .35,
0,
100,
),
})
}
const deriveTopNavTokens = (
baseTheme: BuiltinThemeId,
editable: EditableTopNavTokens,
link: string,
): ResolvedTopNavTokens => ({
rootBackgroundMobile: editable.rootBackgroundMobile,
rootBackgroundDesktop: editable.rootBackgroundDesktop,
activeBackground: editable.activeBackground,
submenuBackground: editable.activeBackground,
mobileMenuBackground: editable.rootBackgroundMobile,
mobileActiveBackground: (
baseTheme === 'dark'
? editable.activeBackground
: editable.rootBackgroundDesktop
),
brandLink: editable.brandLink,
menuLink: link,
})
export const buildThemeTokens = (
baseTheme: BuiltinThemeId,
rawTokens: unknown,
): ThemeTokens => { ): ThemeTokens => {
const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS
const tokens = const tokens =
@@ -922,89 +1027,103 @@ const normaliseThemeTokens = (
? rawTokens as Record<string, unknown> ? rawTokens as Record<string, unknown>
: { } : { }
const background =
normaliseThemeTokenValue (tokens.background, fallback.background)
const foreground =
normaliseThemeTokenValue (tokens.foreground, fallback.foreground)
const card =
normaliseThemeTokenValue (tokens.card, fallback.card)
const muted =
normaliseThemeTokenValue (tokens.muted, fallback.muted)
const primary =
normaliseThemeTokenValue (tokens.primary, fallback.primary)
const accent =
normaliseThemeTokenValue (tokens.accent, fallback.accent)
const border =
normaliseThemeTokenValue (tokens.border, fallback.border)
const link =
normaliseThemeTokenValue (tokens.link, fallback.link)
const topNavRootBackgroundMobile = normaliseTopNavColourValue (
tokens.topNavRootBackgroundMobile
?? legacyTopNavTokens.topNavRootBackgroundMobile
?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundMobile,
)
const topNavRootBackgroundDesktop = normaliseTopNavColourValue (
tokens.topNavRootBackgroundDesktop
?? legacyTopNavTokens.topNavRootBackgroundDesktop
?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundDesktop,
)
const topNavActiveBackground = normaliseTopNavColourValue (
tokens.topNavActiveBackground
?? legacyTopNavTokens.topNavActiveBackground
?? legacyTopNavTokens.topNavHighlightBackground
?? legacyTopNavTokens.topNavHighlightBackground,
fallback.topNavActiveBackground,
)
const topNavBrandLink = normaliseTopNavColourValue (
tokens.topNavBrandLink
?? legacyTopNavTokens.topNavBrandLink
?? legacyTopNavTokens.topNavLink,
fallback.topNavBrandLink,
)
const resolvedTopNav = deriveTopNavTokens (
baseTheme,
{
rootBackgroundMobile: topNavRootBackgroundMobile,
rootBackgroundDesktop: topNavRootBackgroundDesktop,
activeBackground: topNavActiveBackground,
brandLink: topNavBrandLink,
},
link,
)
return { return {
background: normaliseThemeTokenValue (tokens.background, fallback.background), background,
foreground: normaliseThemeTokenValue (tokens.foreground, fallback.foreground), foreground,
card: normaliseThemeTokenValue (tokens.card, fallback.card), card,
cardForeground: normaliseThemeTokenValue ( cardForeground: foreground,
tokens.cardForeground, popover: card,
fallback.cardForeground, popoverForeground: foreground,
), primary,
popover: normaliseThemeTokenValue (tokens.popover, fallback.popover), primaryForeground: readableForegroundFor (primary),
popoverForeground: normaliseThemeTokenValue ( secondary: muted,
tokens.popoverForeground, secondaryForeground: foreground,
fallback.popoverForeground, muted,
), mutedForeground: deriveMutedForeground (
primary: normaliseThemeTokenValue (tokens.primary, fallback.primary), foreground,
primaryForeground: normaliseThemeTokenValue ( background,
tokens.primaryForeground,
fallback.primaryForeground,
),
secondary: normaliseThemeTokenValue (tokens.secondary, fallback.secondary),
secondaryForeground: normaliseThemeTokenValue (
tokens.secondaryForeground,
fallback.secondaryForeground,
),
muted: normaliseThemeTokenValue (tokens.muted, fallback.muted),
mutedForeground: normaliseThemeTokenValue (
tokens.mutedForeground,
fallback.mutedForeground, fallback.mutedForeground,
), ),
accent: normaliseThemeTokenValue (tokens.accent, fallback.accent), accent,
accentForeground: normaliseThemeTokenValue ( accentForeground: readableForegroundFor (accent),
tokens.accentForeground, destructive: fallback.destructive,
fallback.accentForeground, destructiveForeground: fallback.destructiveForeground,
), border,
destructive: normaliseThemeTokenValue ( input: border,
tokens.destructive, ring: primary,
fallback.destructive, link,
), topNavRootBackgroundMobile: resolvedTopNav.rootBackgroundMobile,
destructiveForeground: normaliseThemeTokenValue ( topNavRootBackgroundDesktop: resolvedTopNav.rootBackgroundDesktop,
tokens.destructiveForeground, topNavActiveBackground: resolvedTopNav.activeBackground,
fallback.destructiveForeground, topNavSubmenuBackground: resolvedTopNav.submenuBackground,
), topNavMobileActiveBackground: resolvedTopNav.mobileActiveBackground,
border: normaliseThemeTokenValue (tokens.border, fallback.border), topNavBrandLink: resolvedTopNav.brandLink,
input: normaliseThemeTokenValue (tokens.input, fallback.input), topNavMenuLink: resolvedTopNav.menuLink,
ring: normaliseThemeTokenValue (tokens.ring, fallback.ring),
link: normaliseThemeTokenValue (tokens.link, fallback.link),
topNavRootBackgroundMobile: normaliseTopNavColourValue (
tokens.topNavRootBackgroundMobile ?? legacyTopNavTokens.topNavActiveBackground,
fallback.topNavRootBackgroundMobile,
),
topNavRootBackgroundDesktop: normaliseTopNavColourValue (
tokens.topNavRootBackgroundDesktop ?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundDesktop,
),
topNavActiveBackground: normaliseTopNavColourValue (
tokens.topNavActiveBackground
?? legacyTopNavTokens.topNavHighlightBackground
?? legacyTopNavTokens.topNavActiveBackground,
fallback.topNavActiveBackground,
),
topNavSubmenuBackground: normaliseTopNavColourValue (
tokens.topNavSubmenuBackground,
fallback.topNavSubmenuBackground,
),
topNavMobileActiveBackground: normaliseTopNavColourValue (
tokens.topNavMobileActiveBackground ?? legacyTopNavTokens.topNavBackground,
fallback.topNavMobileActiveBackground,
),
topNavBrandLink: normaliseTopNavColourValue (
tokens.topNavBrandLink
?? legacyTopNavTokens.topNavLink,
fallback.topNavBrandLink,
),
topNavMenuLink: normaliseTopNavColourValue (
tokens.topNavMenuLink
?? tokens.link
?? fallback.topNavMenuLink,
fallback.topNavMenuLink,
),
tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours), tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours),
} }
} }
const normaliseThemeTokens = (
rawTokens: unknown,
baseTheme: BuiltinThemeId,
): ThemeTokens => {
return buildThemeTokens (baseTheme, rawTokens)
}
export const getUserThemeSlotSelection = ( export const getUserThemeSlotSelection = (
baseTheme: BuiltinThemeId, baseTheme: BuiltinThemeId,
slotNo: UserThemeSlotNo, slotNo: UserThemeSlotNo,
@@ -1504,6 +1623,10 @@ export const applyThemeTokens = (tokens: ThemeTokens): void => {
'--top-nav-submenu-bg', '--top-nav-submenu-bg',
tokens.topNavSubmenuBackground, tokens.topNavSubmenuBackground,
) )
root.style.setProperty (
'--top-nav-mobile-menu-bg',
tokens.topNavRootBackgroundMobile,
)
root.style.setProperty ( root.style.setProperty (
'--top-nav-mobile-active-bg', '--top-nav-mobile-active-bg',
tokens.topNavMobileActiveBackground, tokens.topNavMobileActiveBackground,
+125 -113
ファイルの表示
@@ -21,6 +21,7 @@ import { apiPut } from '@/lib/api'
import { import {
applyClientAppearance, applyClientAppearance,
applyThemeAppearanceState, applyThemeAppearanceState,
buildThemeTokens,
fetchUserSettings, fetchUserSettings,
fetchUserThemeSlots, fetchUserThemeSlots,
getClientActiveDarkThemeSlotNo, getClientActiveDarkThemeSlotNo,
@@ -163,33 +164,26 @@ const themeSelections: ThemeSelectionItem[] = [
] ]
const themeTokenGroups: ThemeTokenGroupSpec[] = [ const themeTokenGroups: ThemeTokenGroupSpec[] = [
{ title: '基本', { title: '基本',
fields: [ fields: [
{ key: 'background', label: '背景' }, { key: 'background', label: '背景' },
{ key: 'foreground', label: '文字' }, { key: 'foreground', label: '文字' },
{ key: 'primary', label: '主色' }, { key: 'primary', label: '主色' },
{ key: 'accent', label: 'アクセント' }, { key: 'accent', label: 'アクセント' },
{ key: 'link', label: 'リンク' },
] }, ] },
{ title: 'パネル', { title: '',
fields: [ fields: [
{ key: 'card', label: 'カード背景' }, { key: 'card', label: 'カード' },
{ key: 'muted', label: '薄い背景' }, { key: 'muted', label: '薄い背景' },
{ key: 'border', label: '境界線' }, { key: 'border', label: '境界線' },
{ key: 'input', label: '入力欄' },
] },
{ title: 'リンク',
fields: [
{ key: 'link', label: 'リンク' },
] }, ] },
{ title: '上部ナビ', { title: '上部ナビ',
fields: [ fields: [
{ key: 'topNavRootBackgroundMobile', label: 'スマホ背景' }, { key: 'topNavRootBackgroundMobile', label: 'スマホ背景' },
{ key: 'topNavRootBackgroundDesktop', label: 'PC 背景' }, { key: 'topNavRootBackgroundDesktop', label: 'PC 背景' },
{ key: 'topNavActiveBackground', label: '選択中背景' }, { key: 'topNavActiveBackground', label: '選択中背景' },
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
{ key: 'topNavMobileActiveBackground', label: 'スマホ選択中背景' },
{ key: 'topNavBrandLink', label: 'ロゴ色' }, { key: 'topNavBrandLink', label: 'ロゴ色' },
{ key: 'topNavMenuLink', label: 'メニュー文字色' },
] }, ] },
] ]
@@ -199,7 +193,7 @@ const ThemeSegmentedControl = <T extends string | number,> (
) => ( ) => (
<div <div
role="radiogroup" role="radiogroup"
className="flex flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1"> className="inline-flex w-fit flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
{options.map (option => ( {options.map (option => (
<button <button
key={String (option.value)} key={String (option.value)}
@@ -207,7 +201,7 @@ const ThemeSegmentedControl = <T extends string | number,> (
role="radio" role="radio"
aria-checked={value === option.value} aria-checked={value === option.value}
className={cn ( className={cn (
'min-w-0 rounded-lg px-3 py-2 text-sm font-medium', 'min-w-0 rounded-lg px-3 py-1.5 text-sm font-medium',
'transition-colors focus-visible:outline-none focus-visible:ring-2', 'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2', 'focus-visible:ring-ring focus-visible:ring-offset-2',
value === option.value value === option.value
@@ -307,21 +301,11 @@ const comparableThemeTokensForSelection = (
} }
} }
const serialiseThemeDraft = ( const serialiseThemeTokens = (
themeMode: ClientThemeMode, themeSlots: UserThemeSlotMap,
activeLightThemeSlotNo: UserThemeSlotNo, selection: UserThemeSlotSelection,
activeDarkThemeSlotNo: UserThemeSlotNo,
themeSlots: UserThemeSlotMap,
): string => ): string =>
JSON.stringify ({ JSON.stringify (comparableThemeTokensForSelection (themeSlots, selection))
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
selection,
tokens: comparableThemeTokensForSelection (themeSlots, selection),
})),
})
const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean => const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean =>
@@ -570,8 +554,10 @@ const ThemeSection: FC<ThemeSectionProps> = (
<FieldError messages={settingsFieldErrors.theme ?? []}/> <FieldError messages={settingsFieldErrors.theme ?? []}/>
<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="flex flex-wrap items-center gap-x-4 gap-y-2">
<Label></Label> <div className="shrink-0">
<Label></Label>
</div>
<ThemeSegmentedControl <ThemeSegmentedControl
value={draftThemeMode} value={draftThemeMode}
options={themeModeSelections.map (selection => ({ options={themeModeSelections.map (selection => ({
@@ -580,32 +566,50 @@ const ThemeSection: FC<ThemeSectionProps> = (
onChange={value => onSelectThemeMode (value as ClientThemeMode)}/> onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
</div> </div>
<div className="space-y-2"> <div className="space-y-3">
<Label></Label> <div className="space-y-1">
<select <h3 className="text-sm font-semibold">使</h3>
className={cn (inputClass (false), 'w-full')} <p className="text-sm text-muted-foreground">
value={draftActiveLightThemeSlotNo} 使
onChange={ev =>
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}> </p>
{themeSlotNoSelections.map (selection => ( </div>
<option key={selection.value} value={selection.value}>
{selection.label}
</option>))}
</select>
</div>
<div className="space-y-2"> <div className="grid gap-3 md:grid-cols-2">
<Label></Label> <div className="space-y-2">
<select <Label></Label>
className={cn (inputClass (false), 'w-full')} <select
value={draftActiveDarkThemeSlotNo} className={cn (inputClass (false), 'w-full')}
onChange={ev => value={draftActiveLightThemeSlotNo}
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}> onChange={ev =>
{themeSlotNoSelections.map (selection => ( onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
<option key={selection.value} value={selection.value}> {themeSlotNoSelections.map (selection => (
{selection.label} <option key={selection.value} value={selection.value}>
</option>))} {`ライト ${ selection.value }`}
</select> </option>))}
</select>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<div className="space-y-2">
<Label></Label>
<select
className={cn (inputClass (false), 'w-full')}
value={draftActiveDarkThemeSlotNo}
onChange={ev =>
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}>
{`ダーク ${ selection.value }`}
</option>))}
</select>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
</div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -617,10 +621,6 @@ const ThemeSection: FC<ThemeSectionProps> = (
</Button> </Button>
</div> </div>
<div className="space-y-1 text-sm text-muted-foreground">
<p></p>
</div>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}> <div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink <TagLink
tag={previewTags.general} tag={previewTags.general}
@@ -636,22 +636,20 @@ const ThemeSection: FC<ThemeSectionProps> = (
key={group.title} key={group.title}
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4"> className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<h3 className="text-sm font-semibold">{group.title}</h3> <h3 className="text-sm font-semibold">{group.title}</h3>
<div className="space-y-3"> <div className="space-y-1">
{group.fields.map (field => ( {group.fields.map (field => (
<div <div
key={field.key} key={field.key}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3 className="flex items-center justify-between gap-4 border-b border-border/60 py-2
sm:flex-row sm:items-center sm:justify-between"> last:border-b-0">
<div className="space-y-1"> <p className="text-sm font-medium">{field.label}</p>
<p className="text-sm font-medium">{field.label}</p> <div className="flex items-center gap-3">
</div> <input
<div className="flex flex-wrap items-center gap-3">
<input
type="color" type="color"
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])} value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
onChange={ev => onChange={ev =>
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))} onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
className="h-10 w-16 cursor-pointer rounded border border-border className="h-9 w-14 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/> disabled:opacity-50"/>
</div> </div>
@@ -666,13 +664,13 @@ const ThemeSection: FC<ThemeSectionProps> = (
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-1">
{CATEGORIES.map (category => ( {CATEGORIES.map (category => (
<div <div
key={category} key={category}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3 className="flex items-center justify-between gap-4 border-b border-border/60 py-2
sm:flex-row sm:items-center sm:justify-between"> last:border-b-0">
<div className="space-y-2"> <div className="flex min-w-0 items-center gap-3">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p> <p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}> <div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink <TagLink
@@ -683,12 +681,12 @@ const ThemeSection: FC<ThemeSectionProps> = (
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="color" type="color"
value={selectedTheme.tokens.tagColours[category]} value={selectedTheme.tokens.tagColours[category]}
onChange={ev => onTagColourChange (category, ev.target.value)} onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border className="h-9 w-14 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/> disabled:opacity-50"/>
</div> </div>
@@ -770,22 +768,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
[displayedThemeSelection.selection, draftThemeSlots]) [displayedThemeSelection.selection, draftThemeSlots])
const hasUnsavedThemeChanges = useMemo ( const hasUnsavedThemeChanges = useMemo (
() => () =>
serialiseThemeDraft (draftThemeMode, serialiseThemeTokens (draftThemeSlots, displayedThemeSelection.selection)
draftActiveLightThemeSlotNo, !== serialiseThemeTokens (savedThemeSlots, displayedThemeSelection.selection),
draftActiveDarkThemeSlotNo, [displayedThemeSelection.selection, draftThemeSlots, savedThemeSlots])
draftThemeSlots)
!== serialiseThemeDraft (savedThemeMode,
savedActiveLightThemeSlotNo,
savedActiveDarkThemeSlotNo,
savedThemeSlots),
[draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots,
savedActiveDarkThemeSlotNo,
savedActiveLightThemeSlotNo,
savedThemeMode,
savedThemeSlots])
const savedName = user?.name ?? '' const savedName = user?.name ?? ''
const hasUnsavedAccountChanges = name !== savedName const hasUnsavedAccountChanges = name !== savedName
const hasPageUnsavedChanges = Object.values (dirtyStates).some (state => state.dirty) const hasPageUnsavedChanges = Object.values (dirtyStates).some (state => state.dirty)
@@ -862,7 +847,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
clearUserValidationErrors () clearUserValidationErrors ()
} }
const currentThemeDraftState = (): ThemeDraftState => ({ const currentThemeDraftState = (): ThemeDraftState => ({
themeMode: draftThemeMode, themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo, activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
@@ -875,16 +860,24 @@ const currentThemeDraftState = (): ThemeDraftState => ({
themeSlots: savedThemeSlots }) themeSlots: savedThemeSlots })
const discardThemeChanges = () => { const discardThemeChanges = () => {
setDraftThemeMode (savedThemeMode) const selection = displayedThemeSelection.selection
setDraftActiveLightThemeSlotNo (savedActiveLightThemeSlotNo) const [_, baseTheme, slotNo] = selection.split (':')
setDraftActiveDarkThemeSlotNo (savedActiveDarkThemeSlotNo) const nextThemeSlots = {
setDraftThemeSlots (savedThemeSlots) ...draftThemeSlots,
setCachedUserThemeSlots (savedThemeSlots) [selection]: savedThemeSlots[selection]
setClientThemeAppearance ({ ?? getDefaultUserThemeSlot (
themeMode: savedThemeMode, baseTheme as BuiltinThemeId,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo, Number (slotNo) as UserThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo }) ),
applyClientAppearance (savedThemeSlots) }
setDraftThemeSlots (nextThemeSlots)
setCachedUserThemeSlots (nextThemeSlots)
applyThemeAppearanceState ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
}, nextThemeSlots)
} }
const discardAllDirtyChanges = async (): Promise<void> => { const discardAllDirtyChanges = async (): Promise<void> => {
@@ -939,6 +932,7 @@ const currentThemeDraftState = (): ThemeDraftState => ({
confirmText = '変更を破棄して切り替える', confirmText = '変更を破棄して切り替える',
): Promise<void> => { ): Promise<void> => {
const baseState = currentThemeDraftState () const baseState = currentThemeDraftState ()
let themeSlots = baseState.themeSlots
if (hasUnsavedThemeChanges) if (hasUnsavedThemeChanges)
{ {
@@ -949,14 +943,31 @@ const currentThemeDraftState = (): ThemeDraftState => ({
if (!(confirmed)) if (!(confirmed))
return return
discardThemeChanges () const selection = displayedThemeSelection.selection
const [_, baseTheme, slotNo] = selection.split (':')
themeSlots = {
...baseState.themeSlots,
[selection]: savedThemeSlots[selection]
?? getDefaultUserThemeSlot (
baseTheme as BuiltinThemeId,
Number (slotNo) as UserThemeSlotNo,
),
}
} }
const nextState = buildNextState ( const nextState = buildNextState (
hasUnsavedThemeChanges hasUnsavedThemeChanges
? savedThemeDraftState () ? { ...savedThemeDraftState (), themeSlots }
: baseState) : { ...baseState, themeSlots })
setSavedThemeMode (nextState.themeMode)
setSavedActiveLightThemeSlotNo (nextState.activeLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (nextState.activeDarkThemeSlotNo)
setClientThemeAppearance ({
themeMode: nextState.themeMode,
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
})
applyDraftThemeState ({ applyDraftThemeState ({
themeMode: nextState.themeMode, themeMode: nextState.themeMode,
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo, activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
@@ -1007,12 +1018,21 @@ const currentThemeDraftState = (): ThemeDraftState => ({
updater: (tokens: ThemeTokens) => ThemeTokens, updater: (tokens: ThemeTokens) => ThemeTokens,
) => { ) => {
const selection = displayedThemeSelection.selection const selection = displayedThemeSelection.selection
const fallbackThemeSlot =
getDefaultUserThemeSlot (
displayedThemeSelection.baseTheme,
displayedThemeSelection.slotNo,
)
const currentThemeSlot = const currentThemeSlot =
draftThemeSlots[selection] draftThemeSlots[selection]
?? ensureCompleteThemeSlots ({ })[selection] ?? fallbackThemeSlot
const nextTokens = buildThemeTokens (
currentThemeSlot.baseTheme,
updater (currentThemeSlot.tokens),
)
const nextThemeSlots = { const nextThemeSlots = {
...draftThemeSlots, ...draftThemeSlots,
[selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } } [selection]: { ...currentThemeSlot, tokens: nextTokens } }
applyDraftThemeState ({ themeSlots: nextThemeSlots }) applyDraftThemeState ({ themeSlots: nextThemeSlots })
} }
@@ -1094,16 +1114,8 @@ const currentThemeDraftState = (): ThemeDraftState => ({
const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots) const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)
setSavedThemeMode (draftThemeMode)
setSavedActiveLightThemeSlotNo (draftActiveLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
setSavedThemeSlots (completeThemeSlots) setSavedThemeSlots (completeThemeSlots)
setDraftThemeSlots (completeThemeSlots) setDraftThemeSlots (completeThemeSlots)
// Display mode and active slot numbers are browser-local state.
setClientThemeAppearance ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo })
setCachedUserThemeSlots (completeThemeSlots) setCachedUserThemeSlots (completeThemeSlots)
applyClientAppearance (completeThemeSlots) applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' }) toast ({ title: 'テーマ設定を保存しました.' })