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