設定画面 (#34) #397
+1
-16
@@ -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'
|
||||||
|
|
||||||
@@ -189,21 +186,9 @@ const App: FC = () => {
|
|||||||
fetchUserThemeSlots (),
|
fetchUserThemeSlots (),
|
||||||
])
|
])
|
||||||
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
|
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
|
||||||
const initialSelection = (
|
|
||||||
hasStoredClientThemeSelection ()
|
|
||||||
? getClientActiveThemeSlot ()
|
|
||||||
: (settings.theme === 'system'
|
|
||||||
? 'system'
|
|
||||||
: `builtin:${ settings.theme }`)
|
|
||||||
)
|
|
||||||
const nextSelection = normaliseThemeSlotSelectionAgainstSlots (
|
|
||||||
initialSelection,
|
|
||||||
storedThemeSlots,
|
|
||||||
)
|
|
||||||
setCachedUserThemeSlots (storedThemeSlots)
|
setCachedUserThemeSlots (storedThemeSlots)
|
||||||
|
if (!(hasStoredClientThemeSelection ()))
|
||||||
seedClientThemeMode (settings.theme)
|
seedClientThemeMode (settings.theme)
|
||||||
if (nextSelection !== initialSelection)
|
|
||||||
setClientActiveThemeSlot (nextSelection)
|
|
||||||
applyClientAppearance ()
|
applyClientAppearance ()
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -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')}>
|
||||||
その他 »
|
その他 »
|
||||||
</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'))}>
|
||||||
その他 »
|
その他 »
|
||||||
@@ -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'))}>
|
||||||
その他 »
|
その他 »
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+167
-26
@@ -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 }`
|
||||||
@@ -45,10 +46,11 @@ export type ThemeTokens = {
|
|||||||
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
|
||||||
|
topNavMenuLink: string
|
||||||
tagColours: ThemeTagColours }
|
tagColours: ThemeTagColours }
|
||||||
export type UserThemeSlot = {
|
export type UserThemeSlot = {
|
||||||
baseTheme: BuiltinThemeId
|
baseTheme: BuiltinThemeId
|
||||||
@@ -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>
|
||||||
@@ -212,10 +223,11 @@ export const LIGHT_THEME_TOKENS: ThemeTokens = {
|
|||||||
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',
|
||||||
|
topNavMenuLink: '#1d4ed8',
|
||||||
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS }
|
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%',
|
||||||
@@ -240,10 +252,11 @@ export const DARK_THEME_TOKENS: ThemeTokens = {
|
|||||||
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',
|
||||||
|
topNavMenuLink: '#93c5fd',
|
||||||
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS }
|
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS }
|
||||||
export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
|
export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
|
||||||
light: {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
activeLightThemeSlotNo: UserThemeSlotNo,
|
||||||
|
activeDarkThemeSlotNo: UserThemeSlotNo,
|
||||||
themeSlots: UserThemeSlotMap,
|
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,58 +518,77 @@ 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
|
<select
|
||||||
className={cn (inputClass (false), 'w-full min-w-0 sm:w-64')}
|
className={inputClass (false)}
|
||||||
value={draftActiveThemeSlot}
|
value={draftThemeMode}
|
||||||
onChange={ev =>
|
onChange={ev =>
|
||||||
onSelectThemeSlot (ev.target.value as ThemeSlotSelection)}>
|
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={currentEditableSelection}
|
||||||
|
onChange={ev =>
|
||||||
|
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}
|
||||||
</option>))}
|
</option>))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
if (isUserThemeSlotSelection (draftActiveThemeSlot))
|
|
||||||
{
|
|
||||||
onEditThemeSlot (draftActiveThemeSlot)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onEnsureEditableTheme ()
|
|
||||||
}}>
|
|
||||||
{canEditTheme ? 'この枠を編輯' : '編輯する枠を選ぶ'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{currentEditableSelection && (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onResetThemeSlot (currentEditableSelection)}>
|
onClick={() => onResetThemeSlot (currentEditableSelection)}>
|
||||||
既定に戻す
|
既定に戻す
|
||||||
</Button>)}
|
</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)}>
|
||||||
@@ -596,7 +619,6 @@ const ThemeSection: FC<ThemeSectionProps> = (
|
|||||||
<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 ()
|
|
||||||
if (editableThemeSlot == null)
|
|
||||||
return
|
|
||||||
|
|
||||||
updateDraftThemeSlot (editableThemeSlot, tokens => ({
|
|
||||||
...tokens,
|
...tokens,
|
||||||
[key]: value,
|
[key]: value,
|
||||||
}), { activeThemeSlot: editableThemeSlot })
|
}))
|
||||||
}) ()
|
|
||||||
},
|
},
|
||||||
onTagColourChange: (category, colour) => {
|
onTagColourChange: (category, colour) => {
|
||||||
void (async () => {
|
updateDraftThemeSlot (editingThemeSlot, tokens => ({
|
||||||
const editableThemeSlot = await ensureEditableThemeSlot ()
|
|
||||||
if (editableThemeSlot == null)
|
|
||||||
return
|
|
||||||
|
|
||||||
updateDraftThemeSlot (editableThemeSlot, tokens => ({
|
|
||||||
...tokens,
|
...tokens,
|
||||||
tagColours: {
|
tagColours: {
|
||||||
...tokens.tagColours,
|
...tokens.tagColours,
|
||||||
[category]: colour,
|
[category]: colour,
|
||||||
},
|
},
|
||||||
}), { activeThemeSlot: editableThemeSlot })
|
}))
|
||||||
}) ()
|
|
||||||
},
|
},
|
||||||
onResetThemeSlot: selection => {
|
onResetThemeSlot: selection => {
|
||||||
const baseTheme =
|
const baseTheme =
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする