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