設定画面 (#34) #397

マージ済み
みてるぞ が 31 個のコミットを feature/034 から main へマージ 2026-07-07 00:48:41 +09:00
5個のファイルの変更578行の追加339行の削除
コミット a0c1b8ba31 の変更だけを表示してゐます - すべてのコミットを表示
+12 -27
ファイルの表示
@@ -16,12 +16,9 @@ import { applyClientAppearance,
fetchUserThemeSlots, fetchUserThemeSlots,
fetchUserSettings, fetchUserSettings,
getClientThemeMode, getClientThemeMode,
getClientActiveThemeSlot,
hasStoredClientThemeSelection, hasStoredClientThemeSelection,
normaliseThemeSlotSelectionAgainstSlots,
normaliseUserThemeSlots, normaliseUserThemeSlots,
setCachedUserThemeSlots, setCachedUserThemeSlots,
setClientActiveThemeSlot,
seedClientThemeMode } from '@/lib/settings' seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
@@ -182,30 +179,18 @@ const App: FC = () => {
return return
void (async () => { void (async () => {
try try
{ {
const [settings, themeSlots] = await Promise.all ([ const [settings, themeSlots] = await Promise.all ([
fetchUserSettings (), fetchUserSettings (),
fetchUserThemeSlots (), fetchUserThemeSlots (),
]) ])
const storedThemeSlots = normaliseUserThemeSlots (themeSlots) const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
const initialSelection = ( setCachedUserThemeSlots (storedThemeSlots)
hasStoredClientThemeSelection () if (!(hasStoredClientThemeSelection ()))
? getClientActiveThemeSlot () seedClientThemeMode (settings.theme)
: (settings.theme === 'system' applyClientAppearance ()
? 'system' }
: `builtin:${ settings.theme }`)
)
const nextSelection = normaliseThemeSlotSelectionAgainstSlots (
initialSelection,
storedThemeSlots,
)
setCachedUserThemeSlots (storedThemeSlots)
seedClientThemeMode (settings.theme)
if (nextSelection !== initialSelection)
setClientActiveThemeSlot (nextSelection)
applyClientAppearance ()
}
catch catch
{ {
return return
+14 -15
ファイルの表示
@@ -208,12 +208,11 @@ const TopNav: FC<Props> = ({ user }) => {
return ( return (
<> <>
<nav className="top-nav-root px-3 flex justify-between items-center w-full <nav className="top-nav-root px-3 flex justify-between items-center w-full">
border-b">
<div className="flex items-center gap-2 h-12"> <div className="flex items-center gap-2 h-12">
<PrefetchLink <PrefetchLink
to="/posts" to="/posts"
className="top-nav-link mx-4 text-xl font-bold" className="top-nav-brand-link mx-4 text-xl font-bold"
onClick={() => { onClick={() => {
scroll (0, 0) scroll (0, 0)
}}> }}>
@@ -245,7 +244,7 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el itemsRef.current[i] = el
}} }}
className={cn ('top-nav-link relative z-10 flex h-full items-center px-5', className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}> (i === openItemIdx) && 'font-bold')}>
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
@@ -260,7 +259,7 @@ const TopNav: FC<Props> = ({ user }) => {
setMoreVsbl (true) setMoreVsbl (true)
measure (-1) measure (-1)
}} }}
className={cn ('top-nav-link relative z-10 flex h-full items-center px-5', className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}> (openItemIdx < 0 || moreVsbl) && 'font-bold')}>
&raquo; &raquo;
</PrefetchLink> </PrefetchLink>
@@ -271,7 +270,7 @@ const TopNav: FC<Props> = ({ user }) => {
<button <button
type="button" type="button"
className="top-nav-link md:hidden ml-auto border-0 bg-transparent pr-4" className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4"
onClick={() => { onClick={() => {
setMenuOpen (!(menuOpen)) setMenuOpen (!(menuOpen))
}}> }}>
@@ -345,7 +344,7 @@ const TopNav: FC<Props> = ({ user }) => {
to={subItem.to} to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined} target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)} onClick={() => setMoreVsbl (false)}
className="top-nav-link h-full flex items-center px-3"> className="top-nav-menu-link h-full flex items-center px-3">
{subItem.name} {subItem.name}
</PrefetchLink> </PrefetchLink>
</motion.div>)))} </motion.div>)))}
@@ -372,7 +371,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={item.to.slice (0, 2) === '//' target={item.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="top-nav-link h-full flex items-center px-3"> className="top-nav-menu-link h-full flex items-center px-3">
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
</div>)))} </div>)))}
@@ -411,7 +410,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={item.to.slice (0, 2) === '//' target={item.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="top-nav-link h-full flex items-center px-3"> className="top-nav-menu-link h-full flex items-center px-3">
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
</motion.div>)))} </motion.div>)))}
@@ -432,7 +431,7 @@ const TopNav: FC<Props> = ({ user }) => {
<Fragment key={i}> <Fragment key={i}>
<PrefetchLink <PrefetchLink
to={i === openItemIdx ? item.to : '#'} to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-link w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx) ((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))} && 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => { onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
@@ -462,7 +461,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={subItem.to.slice (0, 2) === '//' target={subItem.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="top-nav-link w-full min-h-[36px] flex items-center pl-12"> className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name} {subItem.name}
</PrefetchLink>)))} </PrefetchLink>)))}
</div>)} </div>)}
@@ -472,7 +471,7 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el itemsRef.current[visibleMenu.length] = el
}} }}
className={cn ('top-nav-link w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0) ((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}> && 'top-nav-mobile-active font-bold'))}>
&raquo; &raquo;
@@ -500,7 +499,7 @@ const TopNav: FC<Props> = ({ user }) => {
<Fragment key={i}> <Fragment key={i}>
<PrefetchLink <PrefetchLink
to={i === openItemIdx ? item.to : '#'} to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-link w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx) ((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))} && 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => { onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
@@ -543,7 +542,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={subItem.to.slice (0, 2) === '//' target={subItem.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="top-nav-link w-full min-h-[36px] flex items-center pl-12"> className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name} {subItem.name}
</PrefetchLink>)))} </PrefetchLink>)))}
</motion.div>)} </motion.div>)}
@@ -554,7 +553,7 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el itemsRef.current[visibleMenu.length] = el
}} }}
className={cn ('top-nav-link w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0) ((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}> && 'top-nav-mobile-active font-bold'))}>
&raquo; &raquo;
+25 -10
ファイルの表示
@@ -36,10 +36,11 @@
--theme-link: 221.2 83.2% 53.3%; --theme-link: 221.2 83.2% 53.3%;
--top-nav-root-bg-mobile: #fef08a; --top-nav-root-bg-mobile: #fef08a;
--top-nav-root-bg-desktop: #fefce8; --top-nav-root-bg-desktop: #fefce8;
--top-nav-highlight-bg: #fef08a; --top-nav-active-bg: #fef08a;
--top-nav-submenu-bg: #fef08a; --top-nav-submenu-bg: #fef08a;
--top-nav-mobile-active-bg: #fefce8; --top-nav-mobile-active-bg: #fefce8;
--top-nav-link: #db2777; --top-nav-brand-link: #db2777;
--top-nav-menu-link: #1d4ed8;
--tag-colour-deerjikist: #9f1239; --tag-colour-deerjikist: #9f1239;
--tag-colour-deerjikist-hover: #b62a51; --tag-colour-deerjikist-hover: #b62a51;
@@ -86,10 +87,11 @@
--ring: 212.7 26.8% 83.9%; --ring: 212.7 26.8% 83.9%;
--top-nav-root-bg-mobile: #230505; --top-nav-root-bg-mobile: #230505;
--top-nav-root-bg-desktop: #230505; --top-nav-root-bg-desktop: #230505;
--top-nav-highlight-bg: #450a0a; --top-nav-active-bg: #450a0a;
--top-nav-submenu-bg: #450a0a; --top-nav-submenu-bg: #450a0a;
--top-nav-mobile-active-bg: #450a0a; --top-nav-mobile-active-bg: #450a0a;
--top-nav-link: #f9a8d4; --top-nav-brand-link: #f9a8d4;
--top-nav-menu-link: #93c5fd;
} }
body body
@@ -212,7 +214,6 @@ body
.top-nav-root .top-nav-root
{ {
background: var(--top-nav-root-bg-mobile); background: var(--top-nav-root-bg-mobile);
border-color: transparent;
} }
@media (min-width: 768px) @media (min-width: 768px)
@@ -223,19 +224,33 @@ body
} }
} }
.top-nav-link .top-nav-brand-link,
.top-nav-mobile-toggle
{ {
color: var(--top-nav-link); color: var(--top-nav-brand-link);
} }
.top-nav-link:hover .top-nav-brand-link:hover,
.top-nav-mobile-toggle:hover
{ {
color: color-mix(in oklab, var(--top-nav-link), white 18%); color: color-mix(in oklab, var(--top-nav-brand-link), white 18%);
}
.top-nav-menu-link,
.top-nav-mobile-row
{
color: var(--top-nav-menu-link);
}
.top-nav-menu-link:hover,
.top-nav-mobile-row:hover
{
color: color-mix(in oklab, var(--top-nav-menu-link), white 18%);
} }
.top-nav-highlight .top-nav-highlight
{ {
background: var(--top-nav-highlight-bg); background: var(--top-nav-active-bg);
} }
.top-nav-submenu .top-nav-submenu
+179 -38
ファイルの表示
@@ -15,6 +15,7 @@ export type ThemeTagColours = Record<Category, string>
export type BuiltinThemeId = 'light' | 'dark' export type BuiltinThemeId = 'light' | 'dark'
export type UserThemeSlotNo = 1 | 2 | 3 export type UserThemeSlotNo = 1 | 2 | 3
export type UserThemeSlotSelection = `user:${ BuiltinThemeId }:${ UserThemeSlotNo }` export type UserThemeSlotSelection = `user:${ BuiltinThemeId }:${ UserThemeSlotNo }`
export type ClientThemeMode = 'system' | 'light' | 'dark'
export type ThemeSlotSelection = export type ThemeSlotSelection =
| 'system' | 'system'
| `builtin:${ BuiltinThemeId }` | `builtin:${ BuiltinThemeId }`
@@ -43,13 +44,14 @@ export type ThemeTokens = {
input: string input: string
ring: string ring: string
link: string link: string
topNavRootBackgroundMobile: string topNavRootBackgroundMobile: string
topNavRootBackgroundDesktop: string topNavRootBackgroundDesktop: string
topNavHighlightBackground: string topNavActiveBackground: string
topNavSubmenuBackground: string topNavSubmenuBackground: string
topNavMobileActiveBackground: string topNavMobileActiveBackground: string
topNavLink: string topNavBrandLink: string
tagColours: ThemeTagColours } topNavMenuLink: string
tagColours: ThemeTagColours }
export type UserThemeSlot = { export type UserThemeSlot = {
baseTheme: BuiltinThemeId baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo slotNo: UserThemeSlotNo
@@ -63,6 +65,12 @@ export type ResolvedThemeSelection = {
baseTheme: BuiltinThemeId baseTheme: BuiltinThemeId
builtin: boolean builtin: boolean
tokens: ThemeTokens } tokens: ThemeTokens }
export type ResolvedDisplayedTheme = {
themeMode: ClientThemeMode
baseTheme: BuiltinThemeId
selection: UserThemeSlotSelection
slotNo: UserThemeSlotNo
tokens: ThemeTokens }
export type ClientTheme = { export type ClientTheme = {
id: ThemeId id: ThemeId
name: string name: string
@@ -97,6 +105,9 @@ export type ClientKeyboardSettings = {
bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>> } bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>> }
export type ClientAppearanceSettings = { export type ClientAppearanceSettings = {
themeMode?: ClientThemeMode
activeLightThemeSlotNo?: UserThemeSlotNo
activeDarkThemeSlotNo?: UserThemeSlotNo
activeThemeSlot?: ThemeSlotSelection activeThemeSlot?: ThemeSlotSelection
activeThemeId?: ActiveThemeId activeThemeId?: ActiveThemeId
customThemes?: Record<string, CustomClientTheme> customThemes?: Record<string, CustomClientTheme>
@@ -210,13 +221,14 @@ export const LIGHT_THEME_TOKENS: ThemeTokens = {
input: '214.3 31.8% 91.4%', input: '214.3 31.8% 91.4%',
ring: '222.2 84% 4.9%', ring: '222.2 84% 4.9%',
link: '221.2 83.2% 53.3%', link: '221.2 83.2% 53.3%',
topNavRootBackgroundMobile: '#fef08a', topNavRootBackgroundMobile: '#fef08a',
topNavRootBackgroundDesktop: '#fefce8', topNavRootBackgroundDesktop: '#fefce8',
topNavHighlightBackground: '#fef08a', topNavActiveBackground: '#fef08a',
topNavSubmenuBackground: '#fef08a', topNavSubmenuBackground: '#fef08a',
topNavMobileActiveBackground: '#fefce8', topNavMobileActiveBackground: '#fefce8',
topNavLink: '#db2777', topNavBrandLink: '#db2777',
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS } topNavMenuLink: '#1d4ed8',
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS }
export const DARK_THEME_TOKENS: ThemeTokens = { export const DARK_THEME_TOKENS: ThemeTokens = {
background: '222.2 84% 4.9%', background: '222.2 84% 4.9%',
foreground: '210 40% 98%', foreground: '210 40% 98%',
@@ -238,13 +250,14 @@ export const DARK_THEME_TOKENS: ThemeTokens = {
input: '217.2 32.6% 17.5%', input: '217.2 32.6% 17.5%',
ring: '212.7 26.8% 83.9%', ring: '212.7 26.8% 83.9%',
link: '213.1 93.9% 67.8%', link: '213.1 93.9% 67.8%',
topNavRootBackgroundMobile: '#230505', topNavRootBackgroundMobile: '#230505',
topNavRootBackgroundDesktop: '#230505', topNavRootBackgroundDesktop: '#230505',
topNavHighlightBackground: '#450a0a', topNavActiveBackground: '#450a0a',
topNavSubmenuBackground: '#450a0a', topNavSubmenuBackground: '#450a0a',
topNavMobileActiveBackground: '#450a0a', topNavMobileActiveBackground: '#450a0a',
topNavLink: '#f9a8d4', topNavBrandLink: '#f9a8d4',
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS } topNavMenuLink: '#93c5fd',
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS }
export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = { export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
light: { light: {
id: 'light', id: 'light',
@@ -356,6 +369,14 @@ const validListLimit = (value: unknown): ClientListLimit | null =>
value === 20 || value === 50 || value === 100 ? value : null value === 20 || value === 50 || value === 100 ? value : null
const normaliseThemeMode = (value: unknown): ClientThemeMode | null =>
value === 'system' || value === 'light' || value === 'dark' ? value : null
const normaliseThemeSlotNo = (value: unknown): UserThemeSlotNo | null =>
value === 1 || value === 2 || value === 3 ? value : null
const normaliseAnimationMode = (value: unknown): ClientAnimationMode | null => { const normaliseAnimationMode = (value: unknown): ClientAnimationMode | null => {
if (value === 'normal' || value === 'reduced' || value === 'off') if (value === 'normal' || value === 'reduced' || value === 'off')
return value return value
@@ -667,6 +688,9 @@ let cachedUserThemeSlots: UserThemeSlotMap = { }
export const hasStoredClientThemeSelection = (): boolean => { export const hasStoredClientThemeSelection = (): boolean => {
const appearance = appearanceFromSettings () const appearance = appearanceFromSettings ()
return ( return (
appearance.themeMode != null
|| appearance.activeLightThemeSlotNo != null
|| appearance.activeDarkThemeSlotNo != null
appearance.activeThemeSlot != null appearance.activeThemeSlot != null
|| appearance.activeThemeId != null || appearance.activeThemeId != null
|| appearance.customThemes != null || appearance.customThemes != null
@@ -720,6 +744,31 @@ const legacyThemeSlotSelection = (): ThemeSlotSelection => {
} }
const legacyThemeMode = (): ClientThemeMode =>
normaliseThemeMode (appearanceFromSettings ().theme)
?? (
legacyThemeSlotSelection () === 'system'
? 'system'
: (baseThemeOfSelection (legacyThemeSlotSelection ()) ?? 'system')
)
const legacyThemeSlotNoFor = (
baseTheme: BuiltinThemeId,
): UserThemeSlotNo => {
const legacySelection = legacyThemeSlotSelection ()
if (isUserThemeSlotSelection (legacySelection))
{
const [_, selectionBaseTheme, slotNo] = legacySelection.split (':')
if (selectionBaseTheme === baseTheme)
return Number (slotNo) as UserThemeSlotNo
}
return 1
}
export const getClientActiveThemeSlot = (): ThemeSlotSelection => export const getClientActiveThemeSlot = (): ThemeSlotSelection =>
normaliseThemeSlotSelection (appearanceFromSettings ().activeThemeSlot) normaliseThemeSlotSelection (appearanceFromSettings ().activeThemeSlot)
?? legacyThemeSlotSelection () ?? legacyThemeSlotSelection ()
@@ -739,21 +788,49 @@ export const setClientActiveThemeSlot = (
export const getClientThemeMode = (): UserSettings['theme'] => { export const getClientThemeMode = (): UserSettings['theme'] => {
const selection = getClientActiveThemeSlot () return (
normaliseThemeMode (appearanceFromSettings ().themeMode)
?? legacyThemeMode ()
)
}
if (selection === 'system')
return 'system'
return baseThemeOfSelection (selection) ?? DEFAULT_USER_SETTINGS.theme export const getClientActiveLightThemeSlotNo = (): UserThemeSlotNo =>
normaliseThemeSlotNo (appearanceFromSettings ().activeLightThemeSlotNo)
?? legacyThemeSlotNoFor ('light')
export const getClientActiveDarkThemeSlotNo = (): UserThemeSlotNo =>
normaliseThemeSlotNo (appearanceFromSettings ().activeDarkThemeSlotNo)
?? legacyThemeSlotNoFor ('dark')
export const setClientThemeAppearance = (
{ themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }: {
themeMode: ClientThemeMode
activeLightThemeSlotNo: UserThemeSlotNo
activeDarkThemeSlotNo: UserThemeSlotNo },
): void => {
updateClientSettings (settings => ({
...settings,
appearance: {
...(settings.appearance ?? { }),
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
},
}))
} }
export const setClientThemeMode = (theme: UserSettings['theme']): void => { export const setClientThemeMode = (theme: UserSettings['theme']): void => {
setClientActiveThemeSlot ( setClientThemeAppearance ({
theme === 'system' themeMode: theme,
? 'system' activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (),
: `builtin:${ theme }`, activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo (),
) })
} }
@@ -893,9 +970,11 @@ const normaliseThemeTokens = (
tokens.topNavRootBackgroundDesktop ?? legacyTopNavTokens.topNavBackground, tokens.topNavRootBackgroundDesktop ?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundDesktop, fallback.topNavRootBackgroundDesktop,
), ),
topNavHighlightBackground: normaliseTopNavColourValue ( topNavActiveBackground: normaliseTopNavColourValue (
tokens.topNavHighlightBackground ?? legacyTopNavTokens.topNavActiveBackground, tokens.topNavActiveBackground
fallback.topNavHighlightBackground, ?? legacyTopNavTokens.topNavHighlightBackground
?? legacyTopNavTokens.topNavActiveBackground,
fallback.topNavActiveBackground,
), ),
topNavSubmenuBackground: normaliseTopNavColourValue ( topNavSubmenuBackground: normaliseTopNavColourValue (
tokens.topNavSubmenuBackground, tokens.topNavSubmenuBackground,
@@ -905,9 +984,16 @@ const normaliseThemeTokens = (
tokens.topNavMobileActiveBackground ?? legacyTopNavTokens.topNavBackground, tokens.topNavMobileActiveBackground ?? legacyTopNavTokens.topNavBackground,
fallback.topNavMobileActiveBackground, fallback.topNavMobileActiveBackground,
), ),
topNavLink: normaliseTopNavColourValue ( topNavBrandLink: normaliseTopNavColourValue (
tokens.topNavLink, tokens.topNavBrandLink
fallback.topNavLink, ?? 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),
} }
@@ -1141,6 +1227,33 @@ export const getCachedUserThemeSlots = (): UserThemeSlotMap =>
cachedUserThemeSlots cachedUserThemeSlots
export const resolveDisplayedTheme = (
{ themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }: {
themeMode: ClientThemeMode
activeLightThemeSlotNo: UserThemeSlotNo
activeDarkThemeSlotNo: UserThemeSlotNo },
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): ResolvedDisplayedTheme => {
const baseTheme = resolveThemeMode (themeMode)
const slotNo =
baseTheme === 'dark'
? activeDarkThemeSlotNo
: activeLightThemeSlotNo
const selection = getUserThemeSlotSelection (baseTheme, slotNo)
return {
themeMode,
baseTheme,
selection,
slotNo,
tokens: themeSlots[selection]?.tokens
?? getDefaultUserThemeSlot (baseTheme, slotNo).tokens,
}
}
export const normaliseThemeSlotSelectionAgainstSlots = ( export const normaliseThemeSlotSelectionAgainstSlots = (
selection: ThemeSlotSelection, selection: ThemeSlotSelection,
themeSlots: UserThemeSlotMap, themeSlots: UserThemeSlotMap,
@@ -1379,8 +1492,8 @@ export const applyThemeTokens = (tokens: ThemeTokens): void => {
tokens.topNavRootBackgroundDesktop, tokens.topNavRootBackgroundDesktop,
) )
root.style.setProperty ( root.style.setProperty (
'--top-nav-highlight-bg', '--top-nav-active-bg',
tokens.topNavHighlightBackground, tokens.topNavActiveBackground,
) )
root.style.setProperty ( root.style.setProperty (
'--top-nav-submenu-bg', '--top-nav-submenu-bg',
@@ -1391,8 +1504,12 @@ export const applyThemeTokens = (tokens: ThemeTokens): void => {
tokens.topNavMobileActiveBackground, tokens.topNavMobileActiveBackground,
) )
root.style.setProperty ( root.style.setProperty (
'--top-nav-link', '--top-nav-brand-link',
tokens.topNavLink, tokens.topNavBrandLink,
)
root.style.setProperty (
'--top-nav-menu-link',
tokens.topNavMenuLink,
) )
applyThemeTagColours (tokens.tagColours) applyThemeTagColours (tokens.tagColours)
@@ -1409,10 +1526,34 @@ export const applyThemeSelection = (
} }
export const applyThemeAppearanceState = (
{ themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }: {
themeMode: ClientThemeMode
activeLightThemeSlotNo: UserThemeSlotNo
activeDarkThemeSlotNo: UserThemeSlotNo },
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): void => {
const theme = resolveDisplayedTheme ({
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
}, themeSlots)
applyThemeMode (theme.baseTheme)
applyThemeTokens (theme.tokens)
}
export const applyClientAppearance = ( export const applyClientAppearance = (
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): void => { ): void => {
applyThemeSelection (getClientActiveThemeSlot (), themeSlots) applyThemeAppearanceState ({
themeMode: getClientThemeMode (),
activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (),
activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo (),
}, themeSlots)
} }
+348 -249
ファイルの表示
@@ -20,20 +20,20 @@ import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { import {
applyClientAppearance, applyClientAppearance,
applyThemeSelection, applyThemeAppearanceState,
DEFAULT_USER_SETTINGS, DEFAULT_USER_SETTINGS,
deriveUserThemeModeFromSlotSelection,
fetchUserSettings, fetchUserSettings,
fetchUserThemeSlots, fetchUserThemeSlots,
getClientActiveThemeSlot, getClientActiveDarkThemeSlotNo,
getClientActiveLightThemeSlotNo,
getClientThemeMode,
getDefaultUserThemeSlot, getDefaultUserThemeSlot,
hasStoredClientThemeSelection, hasStoredClientThemeSelection,
isUserThemeSlotSelection, getUserThemeSlotSelection,
normaliseThemeSlotSelectionAgainstSlots,
normaliseUserThemeSlots, normaliseUserThemeSlots,
resolveDisplayedTheme,
setCachedUserThemeSlots, setCachedUserThemeSlots,
setClientActiveThemeSlot, setClientThemeAppearance,
resolveThemeFromSlotSelection,
updateUserSettings, updateUserSettings,
upsertUserThemeSlot, upsertUserThemeSlot,
USER_THEME_SLOT_SELECTIONS, 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 { Category, Tag, User } from '@/types'
import type { import type {
BuiltinThemeId, BuiltinThemeId,
ClientThemeMode,
ClientTheme, ClientTheme,
ThemeSlotSelection,
ThemeTokens, ThemeTokens,
ThemeTagColours, ThemeTagColours,
UserSettings, UserSettings,
@@ -80,12 +80,15 @@ type AccountSectionProps = {
userBaseErrors: string[] userBaseErrors: string[]
userFieldErrors: Partial<Record<UserFormField, string[]>> } userFieldErrors: Partial<Record<UserFormField, string[]>> }
type ThemeModeItem = {
id: ClientThemeMode
label: string }
type ThemeSelectionItem = { type ThemeSelectionItem = {
id: ThemeSlotSelection id: UserThemeSlotSelection
label: string label: string
editable: boolean baseTheme: BuiltinThemeId
baseTheme?: BuiltinThemeId slotNo: UserThemeSlotNo }
slotNo?: UserThemeSlotNo }
type ThemeTokenFieldSpec = { type ThemeTokenFieldSpec = {
key: ThemeTokenKey key: ThemeTokenKey
@@ -96,17 +99,20 @@ type ThemeTokenGroupSpec = {
fields: ThemeTokenFieldSpec[] } fields: ThemeTokenFieldSpec[] }
type ThemeSectionProps = { type ThemeSectionProps = {
draftActiveThemeSlot: ThemeSlotSelection draftThemeMode: ClientThemeMode
draftThemeSlots: UserThemeSlotMap draftActiveLightThemeSlotNo: UserThemeSlotNo
editingThemeSlot: UserThemeSlotSelection | null draftActiveDarkThemeSlotNo: UserThemeSlotNo
editingThemeSlot: UserThemeSlotSelection
selectedTheme: ClientTheme selectedTheme: ClientTheme
selectedThemeLabel: string displayedThemeLabel: string
systemThemeLabel: string
hasUnsavedChanges: boolean hasUnsavedChanges: boolean
settingsBaseErrors: string[] settingsBaseErrors: string[]
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>> settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
onSelectThemeSlot: (selection: ThemeSlotSelection) => void onSelectThemeMode: (themeMode: ClientThemeMode) => void
onEditThemeSlot: (selection: UserThemeSlotSelection) => void onSelectLightThemeSlotNo: (slotNo: UserThemeSlotNo) => void
onEnsureEditableTheme: () => 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: (selection: UserThemeSlotSelection) => void
@@ -117,25 +123,34 @@ type SettingsTabDirtyState = {
dirty: boolean dirty: boolean
discard: () => void | Promise<void> } discard: () => void | Promise<void> }
type ThemeDraftState = {
themeMode: ClientThemeMode
activeLightThemeSlotNo: UserThemeSlotNo
activeDarkThemeSlotNo: UserThemeSlotNo
themeSlots: UserThemeSlotMap
editingThemeSlot: UserThemeSlotSelection }
const tabs: TabSpec[] = [ const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' }, { id: 'account', label: 'アカウント' },
{ id: 'behavior', label: '動作' }, { id: 'behavior', label: '動作' },
{ id: 'theme', label: 'テーマ' }, { id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' } ] { id: 'keyboard', label: 'キーボード' } ]
const themeModeSelections: ThemeModeItem[] = [
{ id: 'system', label: 'システム設定に従う' },
{ id: 'light', label: 'ライト' },
{ id: 'dark', label: 'ダーク' } ]
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'
const themeSelections: ThemeSelectionItem[] = [ const themeSelections: ThemeSelectionItem[] = [
{ id: 'system', label: 'システム設定', editable: false }, { id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 },
{ id: 'builtin:light', label: '標準ライト', editable: false }, { id: 'user:light:2', label: 'ライト 2', baseTheme: 'light', slotNo: 2 },
{ id: 'builtin:dark', label: '標準ダーク', editable: false }, { id: 'user:light:3', label: 'ライト 3', baseTheme: 'light', slotNo: 3 },
{ id: 'user:light:1', label: 'ライト 1', editable: true, baseTheme: 'light', slotNo: 1 }, { id: 'user:dark:1', label: 'ダーク 1', baseTheme: 'dark', slotNo: 1 },
{ id: 'user:light:2', label: 'ライト 2', editable: true, baseTheme: 'light', slotNo: 2 }, { id: 'user:dark:2', label: 'ダーク 2', baseTheme: 'dark', slotNo: 2 },
{ id: 'user:light:3', label: 'ライト 3', editable: true, baseTheme: 'light', slotNo: 3 }, { id: 'user:dark:3', label: 'ダーク 3', baseTheme: 'dark', 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 },
] ]
const themeTokenGroups: ThemeTokenGroupSpec[] = [ const themeTokenGroups: ThemeTokenGroupSpec[] = [
@@ -159,12 +174,13 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
] }, ] },
{ title: '上部ナビ', { title: '上部ナビ',
fields: [ fields: [
{ key: 'topNavRootBackgroundMobile', label: '通常背景' }, { key: 'topNavRootBackgroundMobile', label: 'スマホ背景' },
{ key: 'topNavRootBackgroundDesktop', label: 'PC 背景' }, { key: 'topNavRootBackgroundDesktop', label: 'PC 背景' },
{ key: 'topNavHighlightBackground', label: '選択中背景' }, { key: 'topNavActiveBackground', label: '選択中背景' },
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' }, { key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
{ key: 'topNavMobileActiveBackground', 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 }`, tagColours[category]],
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties [`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
const themeLabelBySelection = (selection: ThemeSlotSelection): string => const themeLabelBySelection = (selection: UserThemeSlotSelection): string =>
themeSelections.find (item => item.id === selection)?.label ?? 'システム設定' themeSelections.find (item => item.id === selection)?.label ?? 'ライト 1'
const ensureCompleteThemeSlots = ( const ensureCompleteThemeSlots = (
themeSlots: UserThemeSlotMap, themeSlots: UserThemeSlotMap,
@@ -236,31 +252,21 @@ const ensureCompleteThemeSlots = (
) )
const serialiseThemeDraft = ( const serialiseThemeDraft = (
activeThemeSlot: ThemeSlotSelection, themeMode: ClientThemeMode,
themeSlots: UserThemeSlotMap, activeLightThemeSlotNo: UserThemeSlotNo,
activeDarkThemeSlotNo: UserThemeSlotNo,
themeSlots: UserThemeSlotMap,
): string => ): string =>
JSON.stringify ({ JSON.stringify ({
activeThemeSlot, themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({ themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
selection, selection,
tokens: themeSlots[selection]?.tokens, 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 => const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean =>
key.startsWith ('topNav') key.startsWith ('topNav')
@@ -447,32 +453,27 @@ const AccountSection: FC<AccountSectionProps> = (
</section>) </section>)
const ThemeSection: FC<ThemeSectionProps> = ( const ThemeSection: FC<ThemeSectionProps> = (
{ draftActiveThemeSlot, { draftThemeMode,
draftThemeSlots, draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
editingThemeSlot, editingThemeSlot,
selectedTheme, selectedTheme,
selectedThemeLabel, displayedThemeLabel,
systemThemeLabel,
hasUnsavedChanges, hasUnsavedChanges,
settingsBaseErrors, settingsBaseErrors,
settingsFieldErrors, settingsFieldErrors,
onSelectThemeSlot, onSelectThemeMode,
onEditThemeSlot, onSelectLightThemeSlotNo,
onEnsureEditableTheme, onSelectDarkThemeSlotNo,
onSelectEditingThemeSlot,
onThemeTokenChange, onThemeTokenChange,
onTagColourChange, onTagColourChange,
onResetThemeSlot, onResetThemeSlot,
onThemeSubmit, onThemeSubmit,
onThemeDiscard }, onThemeDiscard },
) => { ) => {
const currentSelection = const currentEditableSelection = editingThemeSlot
themeSelections.find (item => item.id === draftActiveThemeSlot)
?? themeSelections[0]
const currentEditableSelection =
editingThemeSlot
?? (isUserThemeSlotSelection (draftActiveThemeSlot)
? draftActiveThemeSlot
: null)
const canEditTheme = currentEditableSelection != null
return ( return (
<section className={sectionClassName}> <section className={sectionClassName}>
@@ -480,12 +481,15 @@ const ThemeSection: FC<ThemeSectionProps> = (
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-bold"></h2> <h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
: {selectedThemeLabel} : {displayedThemeLabel}
</p> </p>
{editingThemeSlot && ( {draftThemeMode === 'system' && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
: {themeLabelBySelection (editingThemeSlot)} : {systemThemeLabel}
</p>)} </p>)}
<p className="text-sm text-muted-foreground">
: {themeLabelBySelection (currentEditableSelection)}
</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">
@@ -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-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>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"> <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 <select
id="theme-slot-select"
className={cn (inputClass (false), 'w-full min-w-0 sm:w-64')} className={cn (inputClass (false), 'w-full min-w-0 sm:w-64')}
value={draftActiveThemeSlot} value={currentEditableSelection}
onChange={ev => onChange={ev =>
onSelectThemeSlot (ev.target.value as ThemeSlotSelection)}> onSelectEditingThemeSlot (ev.target.value as UserThemeSlotSelection)}>
{themeSelections.map (selection => ( {themeSelections.map (selection => (
<option key={selection.id} value={selection.id}> <option key={selection.id} value={selection.id}>
{selection.label} {selection.label}
@@ -529,43 +580,15 @@ const ThemeSection: FC<ThemeSectionProps> = (
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
onClick={() => { onClick={() => onResetThemeSlot (currentEditableSelection)}>
if (isUserThemeSlotSelection (draftActiveThemeSlot))
{
onEditThemeSlot (draftActiveThemeSlot)
return
}
onEnsureEditableTheme ()
}}>
{canEditTheme ? 'この枠を編輯' : '編輯する枠を選ぶ'}
</Button> </Button>
{currentEditableSelection && (
<Button
type="button"
variant="ghost"
onClick={() => onResetThemeSlot (currentEditableSelection)}>
</Button>)}
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1 text-sm text-muted-foreground">
<p className="text-sm font-semibold">{currentSelection.label}</p> <p></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> </div>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}> <div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
@@ -593,10 +616,9 @@ const ThemeSection: FC<ThemeSectionProps> = (
<p className="text-sm font-medium">{field.label}</p> <p className="text-sm font-medium">{field.label}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<input <input
type="color" type="color"
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])} value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
disabled={!(canEditTheme)}
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-10 w-16 cursor-pointer rounded border border-border
@@ -635,7 +657,6 @@ const ThemeSection: FC<ThemeSectionProps> = (
<input <input
type="color" type="color"
value={selectedTheme.tokens.tagColours[category]} value={selectedTheme.tokens.tagColours[category]}
disabled={!(canEditTheme)}
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-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed bg-transparent p-1 disabled:cursor-not-allowed
@@ -659,16 +680,26 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const [name, setName] = useState ('') const [name, setName] = useState ('')
const [settingsError, setSettingsError] = useState<string | null> (null) const [settingsError, setSettingsError] = useState<string | null> (null)
const [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS) const [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
const [savedActiveThemeSlot, setSavedActiveThemeSlot] = const [savedThemeMode, setSavedThemeMode] =
useState<ThemeSlotSelection> (getClientActiveThemeSlot ()) useState<ClientThemeMode> (getClientThemeMode ())
const [draftActiveThemeSlot, setDraftActiveThemeSlot] = const [draftThemeMode, setDraftThemeMode] =
useState<ThemeSlotSelection> (getClientActiveThemeSlot ()) 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] = const [savedThemeSlots, setSavedThemeSlots] =
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] = const [editingThemeSlot, setEditingThemeSlot] =
useState<UserThemeSlotSelection | null> (null) 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)
@@ -684,25 +715,59 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
useValidationErrors<SettingsFormField> () useValidationErrors<SettingsFormField> ()
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search]) 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 selectedTheme = useMemo<ClientTheme> (
() => { () => {
const resolvedTheme = const currentSelection =
resolveThemeFromSlotSelection (draftActiveThemeSlot, draftThemeSlots) themeSelections.find (selection => selection.id === editingThemeSlot)
?? themeSelections[0]
const themeSlot =
draftThemeSlots[currentSelection.id]
?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo)
return { return {
id: resolvedTheme.baseTheme, id: currentSelection.baseTheme,
name: themeLabelBySelection (draftActiveThemeSlot), name: themeLabelBySelection (currentSelection.id),
builtin: resolvedTheme.builtin, builtin: false,
baseTheme: resolvedTheme.baseTheme, baseTheme: currentSelection.baseTheme,
tokens: resolvedTheme.tokens, tokens: themeSlot.tokens,
} }
}, },
[draftActiveThemeSlot, draftThemeSlots], [draftThemeSlots, editingThemeSlot],
) )
const hasUnsavedThemeChanges = useMemo ( const hasUnsavedThemeChanges = useMemo (
() => () =>
serialiseThemeDraft (draftActiveThemeSlot, draftThemeSlots) serialiseThemeDraft (
!== serialiseThemeDraft (savedActiveThemeSlot, savedThemeSlots), draftThemeMode,
[draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots], draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
draftThemeSlots,
)
!== serialiseThemeDraft (
savedThemeMode,
savedActiveLightThemeSlotNo,
savedActiveDarkThemeSlotNo,
savedThemeSlots,
),
[draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots,
editingThemeSlot,
savedActiveDarkThemeSlotNo,
savedActiveLightThemeSlotNo,
savedEditingThemeSlot,
savedThemeMode,
savedThemeSlots],
) )
const applyActiveTab = (tab: SettingsTab) => { const applyActiveTab = (tab: SettingsTab) => {
@@ -753,25 +818,63 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name], [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name],
) )
const applyDraftThemeSelection = ( const applyDraftThemeState = (
activeThemeSlot: ThemeSlotSelection, { themeMode = draftThemeMode,
themeSlots: UserThemeSlotMap, 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) setDraftThemeSlots (themeSlots)
setEditingThemeSlot (nextEditingThemeSlot)
setDraftSettings (current => ({ setDraftSettings (current => ({
...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 = () => { const discardThemeChanges = () => {
setDraftActiveThemeSlot (savedActiveThemeSlot) setDraftThemeMode (savedThemeMode)
setDraftActiveLightThemeSlotNo (savedActiveLightThemeSlotNo)
setDraftActiveDarkThemeSlotNo (savedActiveDarkThemeSlotNo)
setDraftThemeSlots (savedThemeSlots) setDraftThemeSlots (savedThemeSlots)
setEditingThemeSlot (null) setEditingThemeSlot (savedEditingThemeSlot)
setCachedUserThemeSlots (savedThemeSlots) setCachedUserThemeSlots (savedThemeSlots)
setClientActiveThemeSlot (savedActiveThemeSlot) setClientThemeAppearance ({
themeMode: savedThemeMode,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
})
applyClientAppearance (savedThemeSlots) applyClientAppearance (savedThemeSlots)
} }
@@ -800,66 +903,42 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
applyActiveTab (nextTab) applyActiveTab (nextTab)
} }
const requestThemeSlotSelection = async ( const requestThemeStateChange = async (
selection: ThemeSlotSelection, buildNextState: (baseState: ThemeDraftState) => ThemeDraftState,
editingSlot: UserThemeSlotSelection | null, description = 'このまま切り替えると、保存していない変更は失われます。',
cancelText = 'このまま編集する',
confirmText = '変更を破棄して切り替える',
): Promise<void> => { ): Promise<void> => {
if ( const baseState =
selection === draftActiveThemeSlot hasUnsavedThemeChanges
&& editingSlot === editingThemeSlot ? savedThemeDraftState ()
) : currentThemeDraftState ()
return
if (hasUnsavedThemeChanges) if (hasUnsavedThemeChanges)
{ {
const confirmed = await confirmDiscardChanges ( const confirmed = await confirmDiscardChanges (
'このまま切り替えると、保存していない変更は失われます。', description,
'この枠に残る', cancelText,
'変更を破棄して切り替える', confirmText,
) )
if (!(confirmed)) if (!(confirmed))
return return
discardThemeChanges ()
} }
setEditingThemeSlot (editingSlot) const nextState = buildNextState (baseState)
applyDraftThemeSelection (selection, savedThemeSlots)
}
const ensureEditableThemeSlot = async (): Promise<UserThemeSlotSelection | null> => { applyDraftThemeState ({
if (editingThemeSlot != null) themeMode: nextState.themeMode,
return editingThemeSlot activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
if (isUserThemeSlotSelection (draftActiveThemeSlot)) activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
{ themeSlots: nextState.themeSlots,
setEditingThemeSlot (draftActiveThemeSlot) nextEditingThemeSlot: nextState.editingThemeSlot,
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,
})),
}) })
if (choice == null)
return null
setEditingThemeSlot (choice)
applyDraftThemeSelection (choice, savedThemeSlots)
return choice
} }
const updateDraftThemeSlot = ( const updateDraftThemeSlot = (
selection: UserThemeSlotSelection, selection: UserThemeSlotSelection,
updater: (tokens: ThemeTokens) => ThemeTokens, updater: (tokens: ThemeTokens) => ThemeTokens,
options?: { activeThemeSlot?: ThemeSlotSelection },
) => { ) => {
const currentThemeSlot = const currentThemeSlot =
draftThemeSlots[selection] draftThemeSlots[selection]
@@ -872,10 +951,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}, },
} }
applyDraftThemeSelection ( applyDraftThemeState ({
options?.activeThemeSlot ?? draftActiveThemeSlot, themeSlots: nextThemeSlots,
nextThemeSlots, nextEditingThemeSlot: selection,
) })
} }
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => { const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
@@ -944,8 +1023,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const draftThemeSlot = draftThemeSlots[selection] const draftThemeSlot = draftThemeSlots[selection]
const savedThemeSlot = savedThemeSlots[selection] const savedThemeSlot = savedThemeSlots[selection]
const requiresPersist = const requiresPersist =
selection === draftActiveThemeSlot selection === editingThemeSlot
&& isUserThemeSlotSelection (draftActiveThemeSlot)
&& savedThemeSlot?.createdAt == null && savedThemeSlot?.createdAt == null
if ( if (
@@ -969,21 +1047,22 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
} }
} }
const themeMode = const data = await updateUserSettings ({ theme: draftThemeMode })
deriveUserThemeModeFromSlotSelection (draftActiveThemeSlot)
const data = await updateUserSettings ({ theme: themeMode })
const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots) const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)
setDraftSettings ({ ...data, theme: themeMode }) setDraftSettings ({ ...data, theme: draftThemeMode })
setSavedActiveThemeSlot (draftActiveThemeSlot) setSavedThemeMode (draftThemeMode)
setSavedActiveLightThemeSlotNo (draftActiveLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
setSavedThemeSlots (completeThemeSlots) setSavedThemeSlots (completeThemeSlots)
setDraftThemeSlots (completeThemeSlots) setDraftThemeSlots (completeThemeSlots)
setEditingThemeSlot ( setSavedEditingThemeSlot (editingThemeSlot)
isUserThemeSlotSelection (draftActiveThemeSlot) setEditingThemeSlot (editingThemeSlot)
? draftActiveThemeSlot setClientThemeAppearance ({
: null, themeMode: draftThemeMode,
) activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
setClientActiveThemeSlot (draftActiveThemeSlot) activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
})
setCachedUserThemeSlots (completeThemeSlots) setCachedUserThemeSlots (completeThemeSlots)
applyClientAppearance (completeThemeSlots) applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' }) toast ({ title: 'テーマ設定を保存しました.' })
@@ -1026,31 +1105,35 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const storedThemeSlots = normaliseUserThemeSlots (themeSlots) const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
const completeThemeSlots = ensureCompleteThemeSlots (storedThemeSlots) const completeThemeSlots = ensureCompleteThemeSlots (storedThemeSlots)
const initialSelection = ( const themeMode =
hasStoredClientThemeSelection () hasStoredClientThemeSelection ()
? getClientActiveThemeSlot () ? getClientThemeMode ()
: themeToSelection (settings.theme) : settings.theme
) const activeLightThemeSlotNo = getClientActiveLightThemeSlotNo ()
const nextSelection = normaliseThemeSlotSelectionAgainstSlots ( const activeDarkThemeSlotNo = getClientActiveDarkThemeSlotNo ()
initialSelection, const initialEditingThemeSlot = resolveDisplayedTheme ({
storedThemeSlots, themeMode,
) activeLightThemeSlotNo,
const themeMode = deriveUserThemeModeFromSlotSelection (nextSelection) activeDarkThemeSlotNo,
}, completeThemeSlots).selection
setDraftSettings ({ ...settings, theme: themeMode }) setDraftSettings ({ ...settings, theme: themeMode })
setSavedActiveThemeSlot (nextSelection) setSavedThemeMode (themeMode)
setDraftActiveThemeSlot (nextSelection) setDraftThemeMode (themeMode)
setSavedActiveLightThemeSlotNo (activeLightThemeSlotNo)
setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
setSavedThemeSlots (completeThemeSlots) setSavedThemeSlots (completeThemeSlots)
setDraftThemeSlots (completeThemeSlots) setDraftThemeSlots (completeThemeSlots)
setEditingThemeSlot ( setSavedEditingThemeSlot (initialEditingThemeSlot)
isUserThemeSlotSelection (nextSelection) setEditingThemeSlot (initialEditingThemeSlot)
? nextSelection
: null,
)
setCachedUserThemeSlots (completeThemeSlots) setCachedUserThemeSlots (completeThemeSlots)
applyThemeSelection (nextSelection, completeThemeSlots) applyThemeAppearanceState ({
if (nextSelection !== initialSelection) themeMode,
setClientActiveThemeSlot (nextSelection) activeLightThemeSlotNo,
activeDarkThemeSlotNo,
}, completeThemeSlots)
setSettingsError (null) setSettingsError (null)
} }
catch catch
@@ -1083,12 +1166,21 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}, [location.pathname, location.search, settingsFieldErrors.theme]) }, [location.pathname, location.search, settingsFieldErrors.theme])
useEffect (() => { useEffect (() => {
applyThemeSelection (draftActiveThemeSlot, draftThemeSlots) applyThemeAppearanceState ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
}, draftThemeSlots)
return () => { return () => {
applyThemeSelection (savedActiveThemeSlot, savedThemeSlots) setCachedUserThemeSlots (savedThemeSlots)
applyClientAppearance (savedThemeSlots)
} }
}, [draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots]) }, [draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots,
savedThemeSlots])
const accountSectionProps: AccountSectionProps | null = const accountSectionProps: AccountSectionProps | null =
user user
@@ -1109,7 +1201,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
dirty: hasUnsavedThemeChanges, dirty: hasUnsavedThemeChanges,
discard: handleThemeDiscard, discard: handleThemeDiscard,
}) })
}, [hasUnsavedThemeChanges, savedActiveThemeSlot, savedThemeSlots]) }, [hasUnsavedThemeChanges, savedThemeSlots])
useKeyboardShortcuts ({ useKeyboardShortcuts ({
'settings.account': () => { 'settings.account': () => {
@@ -1127,49 +1219,56 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}) })
const themeSectionProps: ThemeSectionProps = { const themeSectionProps: ThemeSectionProps = {
draftActiveThemeSlot, draftThemeMode,
draftThemeSlots, draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
editingThemeSlot, editingThemeSlot,
selectedTheme, selectedTheme,
selectedThemeLabel: themeLabelBySelection (draftActiveThemeSlot), displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection),
systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト',
hasUnsavedChanges: hasUnsavedThemeChanges, hasUnsavedChanges: hasUnsavedThemeChanges,
settingsBaseErrors, settingsBaseErrors,
settingsFieldErrors, settingsFieldErrors,
onSelectThemeSlot: selection => { onSelectThemeMode: themeMode => {
void requestThemeSlotSelection (selection, null) void requestThemeStateChange (baseState => ({
...baseState,
themeMode,
}))
}, },
onEditThemeSlot: selection => { onSelectLightThemeSlotNo: slotNo => {
void requestThemeSlotSelection (selection, selection) void requestThemeStateChange (baseState => ({
...baseState,
activeLightThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('light', slotNo),
}))
}, },
onEnsureEditableTheme: () => { onSelectDarkThemeSlotNo: slotNo => {
void ensureEditableThemeSlot () void requestThemeStateChange (baseState => ({
...baseState,
activeDarkThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo),
}))
},
onSelectEditingThemeSlot: selection => {
void requestThemeStateChange (baseState => ({
...baseState,
editingThemeSlot: selection,
}))
}, },
onThemeTokenChange: (key, value) => { onThemeTokenChange: (key, value) => {
void (async () => { updateDraftThemeSlot (editingThemeSlot, tokens => ({
const editableThemeSlot = await ensureEditableThemeSlot () ...tokens,
if (editableThemeSlot == null) [key]: value,
return }))
updateDraftThemeSlot (editableThemeSlot, tokens => ({
...tokens,
[key]: value,
}), { activeThemeSlot: editableThemeSlot })
}) ()
}, },
onTagColourChange: (category, colour) => { onTagColourChange: (category, colour) => {
void (async () => { updateDraftThemeSlot (editingThemeSlot, tokens => ({
const editableThemeSlot = await ensureEditableThemeSlot () ...tokens,
if (editableThemeSlot == null) tagColours: {
return ...tokens.tagColours,
[category]: colour,
updateDraftThemeSlot (editableThemeSlot, tokens => ({ },
...tokens, }))
tagColours: {
...tokens.tagColours,
[category]: colour,
},
}), { activeThemeSlot: editableThemeSlot })
}) ()
}, },
onResetThemeSlot: selection => { onResetThemeSlot: selection => {
const baseTheme = const baseTheme =