f5b632ed89
Reviewed-on: #397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
1722 行
54 KiB
TypeScript
1722 行
54 KiB
TypeScript
import { CATEGORIES } from '@/consts'
|
|
import { apiGet, apiPatch, apiPut } from '@/lib/api'
|
|
import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts'
|
|
|
|
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
|
|
import type { KeyBinding, ShortcutActionId } from '@/lib/keyboardShortcuts'
|
|
|
|
export type UserSettings = {
|
|
theme: 'system' | 'light' | 'dark'
|
|
autoFetchTitle: 'auto' | 'manual' | 'off'
|
|
autoFetchThumbnail: 'auto' | 'manual' | 'off'
|
|
wikiEditorMode: 'split' | 'write' | 'preview' }
|
|
|
|
export type ThemeTagColours = Record<Category, string>
|
|
export type BuiltinThemeId = 'light' | 'dark'
|
|
export type UserThemeSlotNo = 1 | 2 | 3
|
|
export type UserThemeSlotSelection = `user:${ BuiltinThemeId }:${ UserThemeSlotNo }`
|
|
export type ClientThemeMode = 'system' | 'light' | 'dark'
|
|
export type ThemeSlotSelection =
|
|
| 'system'
|
|
| `builtin:${ BuiltinThemeId }`
|
|
| UserThemeSlotSelection
|
|
export type ThemeId = BuiltinThemeId | `custom:${ string }`
|
|
export type ActiveThemeId = ThemeId | 'system'
|
|
export type ResolvedTheme = 'light' | 'dark'
|
|
export type ThemeTokens = {
|
|
background: string
|
|
foreground: string
|
|
card: string
|
|
cardForeground: string
|
|
popover: string
|
|
popoverForeground: string
|
|
primary: string
|
|
primaryForeground: string
|
|
secondary: string
|
|
secondaryForeground: string
|
|
muted: string
|
|
mutedForeground: string
|
|
accent: string
|
|
accentForeground: string
|
|
destructive: string
|
|
destructiveForeground: string
|
|
border: string
|
|
input: string
|
|
ring: string
|
|
link: string
|
|
topNavRootBackgroundMobile: string
|
|
topNavRootBackgroundDesktop: string
|
|
topNavActiveBackground: string
|
|
topNavSubmenuBackground: string
|
|
topNavMobileActiveBackground: string
|
|
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
|
|
tokens: ThemeTokens
|
|
createdAt?: string
|
|
updatedAt?: string }
|
|
export type UserThemeSlotMap =
|
|
Partial<Record<UserThemeSlotSelection, UserThemeSlot>>
|
|
export type ResolvedThemeSelection = {
|
|
selection: ThemeSlotSelection
|
|
baseTheme: BuiltinThemeId
|
|
builtin: boolean
|
|
tokens: ThemeTokens }
|
|
export type ResolvedDisplayedTheme = {
|
|
themeMode: ClientThemeMode
|
|
baseTheme: BuiltinThemeId
|
|
selection: UserThemeSlotSelection
|
|
slotNo: UserThemeSlotNo
|
|
tokens: ThemeTokens }
|
|
export type ClientTheme = {
|
|
id: ThemeId
|
|
name: string
|
|
builtin: boolean
|
|
baseTheme: BuiltinThemeId
|
|
tokens: ThemeTokens }
|
|
export type CustomClientTheme =
|
|
ClientTheme & { builtin: false }
|
|
|
|
export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
|
|
export type TheatreTagFlow = 'vertical' | 'horizontal'
|
|
export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off'
|
|
export type ClientAnimationMode = 'normal' | 'reduced' | 'off'
|
|
export type ClientEmbedAutoLoadMode = 'auto' | 'manual' | 'off'
|
|
export type ClientLinkPreloadMode = 'off' | 'intent'
|
|
export type ClientTagRelationDisplayMode = 'flat' | 'grouped'
|
|
export type ClientThumbnailMode = 'normal' | 'light' | 'off'
|
|
export type ClientPaneBreakpoint = 'desktop' | 'tablet'
|
|
export type ClientListKey = 'postList' | 'postSearch' | 'tagList'
|
|
export type ClientListLimit = 20 | 50 | 100
|
|
|
|
type ClientPaneSettings = {
|
|
widthPxByBreakpoint?: Partial<Record<ClientPaneBreakpoint, number>>
|
|
collapsed?: boolean }
|
|
|
|
type ClientListSettings = {
|
|
limit?: ClientListLimit
|
|
order?: string }
|
|
|
|
export type ClientKeyboardSettings = {
|
|
enabled?: boolean
|
|
bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>> }
|
|
|
|
export type ClientAppearanceSettings = {
|
|
themeMode?: ClientThemeMode
|
|
activeLightThemeSlotNo?: UserThemeSlotNo
|
|
activeDarkThemeSlotNo?: UserThemeSlotNo
|
|
activeThemeSlot?: ThemeSlotSelection
|
|
activeThemeId?: ActiveThemeId
|
|
customThemes?: Record<string, CustomClientTheme>
|
|
// Legacy fields kept for migration from the earlier appearance model.
|
|
theme?: UserSettings['theme']
|
|
tagColours?: Partial<ThemeTagColours> }
|
|
|
|
export type ClientBehaviorSettings = {
|
|
animation?: ClientAnimationMode
|
|
linkPreload?: ClientLinkPreloadMode
|
|
tagRelationDisplay?: ClientTagRelationDisplayMode
|
|
embedAutoLoad?: ClientEmbedAutoLoadMode
|
|
thumbnailMode?: ClientThumbnailMode }
|
|
|
|
// DB-backed settings. At present only `theme` is surfaced in `/users/settings`.
|
|
// The remaining fields are retained for existing backend work and are not wired
|
|
// to the common settings UI yet.
|
|
export const DEFAULT_USER_SETTINGS: UserSettings = {
|
|
theme: 'system',
|
|
autoFetchTitle: 'manual',
|
|
autoFetchThumbnail: 'manual',
|
|
wikiEditorMode: 'split' }
|
|
|
|
// Browser-local settings only. These are device or screen specific.
|
|
export type ClientSettings = {
|
|
appearance?: ClientAppearanceSettings
|
|
behavior?: ClientBehaviorSettings
|
|
keyboard?: ClientKeyboardSettings
|
|
panes?: Record<string, ClientPaneSettings>
|
|
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
|
theatre?: { layoutMode?: TheatreLayoutMode
|
|
tagFlow?: TheatreTagFlow }
|
|
gekanator?: { backgroundMotion?: GekanatorBackgroundMotionMode }
|
|
reducedMotion?: string
|
|
embedAutoLoad?: string
|
|
thumbnailMode?: string }
|
|
|
|
export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings'
|
|
export const CLIENT_SETTINGS_EVENT = 'btrc-hub:client-settings-change'
|
|
export const USER_THEME_SLOT_NOS: UserThemeSlotNo[] = [1, 2, 3]
|
|
export const USER_THEME_SLOT_SELECTIONS: UserThemeSlotSelection[] = [
|
|
'user:light:1',
|
|
'user:light:2',
|
|
'user:light:3',
|
|
'user:dark:1',
|
|
'user:dark:2',
|
|
'user:dark:3']
|
|
export const THEME_OPTIONS = ['system', 'light', 'dark'] as const
|
|
export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const
|
|
export const POST_LIST_ORDER_OPTIONS = [
|
|
'title:asc',
|
|
'title:desc',
|
|
'url:asc',
|
|
'url:desc',
|
|
'original_created_at:asc',
|
|
'original_created_at:desc',
|
|
'created_at:asc',
|
|
'created_at:desc',
|
|
'updated_at:asc',
|
|
'updated_at:desc'] as const satisfies readonly FetchPostsOrder[]
|
|
export const TAG_LIST_ORDER_OPTIONS = [
|
|
'name:asc',
|
|
'name:desc',
|
|
'category:asc',
|
|
'category:desc',
|
|
'post_count:asc',
|
|
'post_count:desc',
|
|
'created_at:asc',
|
|
'created_at:desc',
|
|
'updated_at:asc',
|
|
'updated_at:desc'] as const satisfies readonly FetchTagsOrder[]
|
|
export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20
|
|
export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc'
|
|
export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc'
|
|
export const DEFAULT_LIGHT_THEME_TAG_COLOURS: ThemeTagColours = {
|
|
deerjikist: '#9f1239',
|
|
meme: '#6b21a8',
|
|
character: '#4d7c0f',
|
|
general: '#155e75',
|
|
material: '#c2410c',
|
|
meta: '#a16207',
|
|
nico: '#4b5563' }
|
|
export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = {
|
|
deerjikist: '#fb7185',
|
|
meme: '#d8b4fe',
|
|
character: '#bef264',
|
|
general: '#67e8f9',
|
|
material: '#fdba74',
|
|
meta: '#fde047',
|
|
nico: '#e5e7eb' }
|
|
export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS
|
|
export const LIGHT_THEME_TOKENS: ThemeTokens = {
|
|
background: '0 0% 100%',
|
|
foreground: '222.2 84% 4.9%',
|
|
card: '0 0% 100%',
|
|
cardForeground: '222.2 84% 4.9%',
|
|
popover: '0 0% 100%',
|
|
popoverForeground: '222.2 84% 4.9%',
|
|
primary: '222.2 47.4% 11.2%',
|
|
primaryForeground: '210 40% 98%',
|
|
secondary: '210 40% 96.1%',
|
|
secondaryForeground: '222.2 47.4% 11.2%',
|
|
muted: '210 40% 96.1%',
|
|
mutedForeground: '215.4 16.3% 46.9%',
|
|
accent: '210 40% 96.1%',
|
|
accentForeground: '222.2 47.4% 11.2%',
|
|
destructive: '0 72.2% 50.6%',
|
|
destructiveForeground: '210 40% 98%',
|
|
border: '214.3 31.8% 91.4%',
|
|
input: '214.3 31.8% 91.4%',
|
|
ring: '222.2 84% 4.9%',
|
|
link: '221.2 83.2% 53.3%',
|
|
topNavRootBackgroundMobile: '#fef08a',
|
|
topNavRootBackgroundDesktop: '#fefce8',
|
|
topNavActiveBackground: '#fef08a',
|
|
topNavSubmenuBackground: '#fef08a',
|
|
topNavMobileActiveBackground: '#fefce8',
|
|
topNavBrandLink: '#db2777',
|
|
topNavMenuLink: '#1d4ed8',
|
|
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS }
|
|
export const DARK_THEME_TOKENS: ThemeTokens = {
|
|
background: '222.2 84% 4.9%',
|
|
foreground: '210 40% 98%',
|
|
card: '222.2 84% 4.9%',
|
|
cardForeground: '210 40% 98%',
|
|
popover: '222.2 84% 4.9%',
|
|
popoverForeground: '210 40% 98%',
|
|
primary: '210 40% 98%',
|
|
primaryForeground: '222.2 47.4% 11.2%',
|
|
secondary: '217.2 32.6% 17.5%',
|
|
secondaryForeground: '210 40% 98%',
|
|
muted: '217.2 32.6% 17.5%',
|
|
mutedForeground: '215 20.2% 65.1%',
|
|
accent: '217.2 32.6% 17.5%',
|
|
accentForeground: '210 40% 98%',
|
|
destructive: '0 62.8% 45%',
|
|
destructiveForeground: '210 40% 98%',
|
|
border: '217.2 32.6% 17.5%',
|
|
input: '217.2 32.6% 17.5%',
|
|
ring: '212.7 26.8% 83.9%',
|
|
link: '213.1 93.9% 67.8%',
|
|
topNavRootBackgroundMobile: '#230505',
|
|
topNavRootBackgroundDesktop: '#230505',
|
|
topNavActiveBackground: '#450a0a',
|
|
topNavSubmenuBackground: '#450a0a',
|
|
topNavMobileActiveBackground: '#450a0a',
|
|
topNavBrandLink: '#f9a8d4',
|
|
topNavMenuLink: '#93c5fd',
|
|
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS }
|
|
export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
|
|
light: { id: 'light',
|
|
name: 'ライト・モード',
|
|
builtin: true,
|
|
baseTheme: 'light',
|
|
tokens: LIGHT_THEME_TOKENS },
|
|
dark: { id: 'dark',
|
|
name: 'ダーク・モード',
|
|
builtin: true,
|
|
baseTheme: 'dark',
|
|
tokens: DARK_THEME_TOKENS } }
|
|
|
|
const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
|
|
const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
|
|
const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY = 'gekanator:background-motion:v1'
|
|
const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated'
|
|
|
|
|
|
const createCustomThemeId = (): `custom:${ string }` => {
|
|
const randomPart =
|
|
globalThis.crypto?.randomUUID?.()
|
|
?? `${ Date.now ().toString (36) }-${ Math.random ().toString (36).slice (2) }`
|
|
|
|
return `custom:${ randomPart }`
|
|
}
|
|
|
|
|
|
export const fetchUserSettings = async (): Promise<UserSettings> => {
|
|
const raw = await apiGet<{
|
|
theme: UserSettings['theme']
|
|
auto_fetch_title: UserSettings['autoFetchTitle']
|
|
auto_fetch_thumbnail: UserSettings['autoFetchThumbnail']
|
|
wiki_editor_mode: UserSettings['wikiEditorMode'] }> ('/users/settings')
|
|
|
|
return {
|
|
theme: raw.theme,
|
|
autoFetchTitle: raw.auto_fetch_title,
|
|
autoFetchThumbnail: raw.auto_fetch_thumbnail,
|
|
wikiEditorMode: raw.wiki_editor_mode }
|
|
}
|
|
|
|
|
|
export const updateUserSettings = async (
|
|
settings: Partial<{ theme: UserSettings['theme'] }>,
|
|
): Promise<UserSettings> => {
|
|
const raw = await apiPatch<{
|
|
theme: UserSettings['theme']
|
|
auto_fetch_title: UserSettings['autoFetchTitle']
|
|
auto_fetch_thumbnail: UserSettings['autoFetchThumbnail']
|
|
wiki_editor_mode: UserSettings['wikiEditorMode'] }> ('/users/settings', settings)
|
|
|
|
return {
|
|
theme: raw.theme,
|
|
autoFetchTitle: raw.auto_fetch_title,
|
|
autoFetchThumbnail: raw.auto_fetch_thumbnail,
|
|
wikiEditorMode: raw.wiki_editor_mode }
|
|
}
|
|
|
|
|
|
const safeParseClientSettings = (raw: string | null): ClientSettings => {
|
|
if (!(raw))
|
|
return { }
|
|
|
|
try
|
|
{
|
|
const parsed = JSON.parse (raw)
|
|
return parsed && typeof parsed === 'object' ? parsed as ClientSettings : { }
|
|
}
|
|
catch
|
|
{
|
|
return { }
|
|
}
|
|
}
|
|
|
|
|
|
export const loadClientSettings = (): ClientSettings =>
|
|
safeParseClientSettings (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY))
|
|
|
|
|
|
export const saveClientSettings = (settings: ClientSettings): void => {
|
|
localStorage.setItem (CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify (settings))
|
|
|
|
if (typeof window !== 'undefined')
|
|
{
|
|
window.dispatchEvent (new CustomEvent (CLIENT_SETTINGS_EVENT, {
|
|
detail: settings }))
|
|
}
|
|
}
|
|
|
|
|
|
export const updateClientSettings = (
|
|
updater: (settings: ClientSettings) => ClientSettings,
|
|
): ClientSettings => {
|
|
const next = updater (loadClientSettings ())
|
|
saveClientSettings (next)
|
|
return next
|
|
}
|
|
|
|
|
|
const validListLimit = (value: unknown): ClientListLimit | 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 => {
|
|
if (value === 'normal' || value === 'reduced' || value === 'off')
|
|
return value
|
|
if (value === 'true')
|
|
return 'reduced'
|
|
if (value === 'false')
|
|
return 'normal'
|
|
return null
|
|
}
|
|
|
|
|
|
const normaliseEmbedAutoLoadMode = (value: unknown): ClientEmbedAutoLoadMode | null =>
|
|
value === 'auto' || value === 'manual' || value === 'off' ? value : null
|
|
|
|
|
|
const normaliseLinkPreloadMode = (
|
|
value: unknown,
|
|
): ClientLinkPreloadMode | null =>
|
|
value === 'off' || value === 'intent' ? value : null
|
|
|
|
|
|
const normaliseTagRelationDisplayMode = (
|
|
value: unknown,
|
|
): ClientTagRelationDisplayMode | null =>
|
|
value === 'flat' || value === 'grouped' ? value : null
|
|
|
|
|
|
const normaliseThumbnailMode = (value: unknown): ClientThumbnailMode | null =>
|
|
value === 'normal' || value === 'light' || value === 'off' ? value : null
|
|
|
|
|
|
const normaliseKeyboardBindings = (
|
|
bindings: unknown,
|
|
): Partial<Record<ShortcutActionId, KeyBinding | null>> => {
|
|
if (!(bindings) || typeof bindings !== 'object')
|
|
return { }
|
|
|
|
return (
|
|
Object.keys (DEFAULT_KEY_BINDINGS)
|
|
.reduce<Partial<Record<ShortcutActionId, KeyBinding | null>>> ((normalised, actionId) => {
|
|
const value = (bindings as Record<string, unknown>)[actionId]
|
|
if (value === null)
|
|
{
|
|
normalised[actionId as ShortcutActionId] = null
|
|
return normalised
|
|
}
|
|
|
|
const binding = normaliseKeyBinding (value)
|
|
if (binding != null)
|
|
normalised[actionId as ShortcutActionId] = binding
|
|
|
|
return normalised
|
|
}, { }))
|
|
}
|
|
|
|
|
|
export const getClientKeyboardSettings = (): ClientKeyboardSettings => {
|
|
const keyboard = loadClientSettings ().keyboard
|
|
|
|
return {
|
|
enabled: keyboard?.enabled !== false,
|
|
bindings: normaliseKeyboardBindings (keyboard?.bindings) }
|
|
}
|
|
|
|
|
|
const legacyAnimationMode = (): ClientAnimationMode | null =>
|
|
normaliseAnimationMode (loadClientSettings ().reducedMotion)
|
|
|
|
|
|
const legacyEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode | null =>
|
|
normaliseEmbedAutoLoadMode (loadClientSettings ().embedAutoLoad)
|
|
|
|
|
|
const legacyThumbnailMode = (): ClientThumbnailMode | null =>
|
|
normaliseThumbnailMode (loadClientSettings ().thumbnailMode)
|
|
|
|
|
|
export const getClientAnimationMode = (): ClientAnimationMode =>
|
|
loadClientSettings ().behavior?.animation
|
|
?? legacyAnimationMode ()
|
|
?? 'normal'
|
|
|
|
|
|
export const applyClientAnimationMode = (mode: ClientAnimationMode): void => {
|
|
if (typeof document === 'undefined')
|
|
return
|
|
|
|
if (mode === 'normal')
|
|
{
|
|
delete document.documentElement.dataset.animation
|
|
return
|
|
}
|
|
|
|
document.documentElement.dataset.animation = mode
|
|
}
|
|
|
|
|
|
export const setClientAnimationMode = (
|
|
animation: ClientAnimationMode,
|
|
): ClientBehaviorSettings =>
|
|
setClientBehaviorSettings ({ ...getClientBehaviorSettings (), animation })
|
|
|
|
|
|
export const getClientLinkPreloadMode = (): ClientLinkPreloadMode =>
|
|
normaliseLinkPreloadMode (loadClientSettings ().behavior?.linkPreload)
|
|
?? 'intent'
|
|
|
|
|
|
export const setClientLinkPreloadMode = (
|
|
linkPreload: ClientLinkPreloadMode,
|
|
): ClientBehaviorSettings =>
|
|
setClientBehaviorSettings ({ ...getClientBehaviorSettings (), linkPreload })
|
|
|
|
|
|
export const getClientTagRelationDisplayMode = (): ClientTagRelationDisplayMode =>
|
|
normaliseTagRelationDisplayMode (loadClientSettings ().behavior?.tagRelationDisplay)
|
|
?? 'grouped'
|
|
|
|
|
|
export const setClientTagRelationDisplayMode = (
|
|
tagRelationDisplay: ClientTagRelationDisplayMode,
|
|
): ClientBehaviorSettings =>
|
|
setClientBehaviorSettings ({ ...getClientBehaviorSettings (), tagRelationDisplay })
|
|
|
|
|
|
export const getClientEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode =>
|
|
loadClientSettings ().behavior?.embedAutoLoad
|
|
?? legacyEmbedAutoLoadMode ()
|
|
?? 'auto'
|
|
|
|
|
|
export const setClientEmbedAutoLoadMode = (
|
|
embedAutoLoad: ClientEmbedAutoLoadMode,
|
|
): ClientBehaviorSettings =>
|
|
setClientBehaviorSettings ({ ...getClientBehaviorSettings (), embedAutoLoad })
|
|
|
|
|
|
export const getClientThumbnailMode = (): ClientThumbnailMode =>
|
|
loadClientSettings ().behavior?.thumbnailMode
|
|
?? legacyThumbnailMode ()
|
|
?? 'normal'
|
|
|
|
|
|
export const setClientThumbnailMode = (
|
|
thumbnailMode: ClientThumbnailMode,
|
|
): ClientBehaviorSettings =>
|
|
setClientBehaviorSettings ({ ...getClientBehaviorSettings (), thumbnailMode })
|
|
|
|
|
|
export const getClientBehaviorSettings = (): ClientBehaviorSettings => ({
|
|
animation: getClientAnimationMode (),
|
|
linkPreload: getClientLinkPreloadMode (),
|
|
tagRelationDisplay: getClientTagRelationDisplayMode (),
|
|
embedAutoLoad: getClientEmbedAutoLoadMode (),
|
|
thumbnailMode: getClientThumbnailMode () })
|
|
|
|
|
|
export const setClientBehaviorSettings = (
|
|
behavior: ClientBehaviorSettings,
|
|
): ClientBehaviorSettings => {
|
|
const next: ClientBehaviorSettings = {
|
|
animation: (
|
|
normaliseAnimationMode (behavior.animation)
|
|
?? getClientAnimationMode ()),
|
|
linkPreload: (
|
|
normaliseLinkPreloadMode (behavior.linkPreload)
|
|
?? getClientLinkPreloadMode ()),
|
|
tagRelationDisplay: (
|
|
normaliseTagRelationDisplayMode (behavior.tagRelationDisplay)
|
|
?? getClientTagRelationDisplayMode ()),
|
|
embedAutoLoad: (
|
|
normaliseEmbedAutoLoadMode (behavior.embedAutoLoad)
|
|
?? getClientEmbedAutoLoadMode ()),
|
|
thumbnailMode: (
|
|
normaliseThumbnailMode (behavior.thumbnailMode)
|
|
?? getClientThumbnailMode ()) }
|
|
|
|
updateClientSettings (settings => ({ ...settings, behavior: next }))
|
|
applyClientAnimationMode (next.animation ?? 'normal')
|
|
return next
|
|
}
|
|
|
|
|
|
export const setClientKeyboardSettings = (
|
|
keyboard: ClientKeyboardSettings,
|
|
): ClientKeyboardSettings => {
|
|
const next: ClientKeyboardSettings = {
|
|
enabled: keyboard.enabled !== false,
|
|
bindings: normaliseKeyboardBindings (keyboard.bindings) }
|
|
|
|
updateClientSettings (settings => ({ ...settings, keyboard: next }))
|
|
|
|
return next
|
|
}
|
|
|
|
|
|
export const getEffectiveKeyBindings = (
|
|
bindings: Partial<Record<ShortcutActionId, KeyBinding | null>> =
|
|
getClientKeyboardSettings ().bindings ?? { },
|
|
): Record<ShortcutActionId, KeyBinding | null> =>
|
|
Object.keys (DEFAULT_KEY_BINDINGS).reduce<Record<ShortcutActionId, KeyBinding | null>> (
|
|
(effectiveBindings, actionId) => {
|
|
const key = actionId as ShortcutActionId
|
|
effectiveBindings[key] =
|
|
Object.prototype.hasOwnProperty.call (bindings, key)
|
|
? bindings[key] ?? null
|
|
: DEFAULT_KEY_BINDINGS[key]
|
|
return effectiveBindings
|
|
},
|
|
{ ...DEFAULT_KEY_BINDINGS })
|
|
|
|
|
|
export const setClientKeyBinding = (
|
|
actionId: ShortcutActionId,
|
|
binding: KeyBinding | null,
|
|
): ClientKeyboardSettings => {
|
|
const current = getClientKeyboardSettings ()
|
|
|
|
return setClientKeyboardSettings ({
|
|
...current,
|
|
bindings: { ...(current.bindings ?? { }), [actionId]: binding } })
|
|
}
|
|
|
|
|
|
export const resetClientKeyBinding = (
|
|
actionId: ShortcutActionId,
|
|
): ClientKeyboardSettings => {
|
|
const current = getClientKeyboardSettings ()
|
|
const nextBindings = { ...(current.bindings ?? { }) }
|
|
delete nextBindings[actionId]
|
|
|
|
return setClientKeyboardSettings ({ ...current, bindings: nextBindings })
|
|
}
|
|
|
|
|
|
export const resetAllClientKeyBindings = (): ClientKeyboardSettings => {
|
|
const current = getClientKeyboardSettings ()
|
|
|
|
return setClientKeyboardSettings ({ ...current, bindings: { } })
|
|
}
|
|
|
|
|
|
const normaliseHexColour = (value: string): string | null =>
|
|
/^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null
|
|
|
|
|
|
const isBuiltinThemeId = (value: string): value is BuiltinThemeId =>
|
|
value === 'light' || value === 'dark'
|
|
|
|
|
|
const isCustomThemeId = (value: string): value is `custom:${ string }` =>
|
|
value.startsWith ('custom:')
|
|
|
|
|
|
const themeCopyName = (baseTheme: BuiltinThemeId): string =>
|
|
`${ BUILTIN_THEMES[baseTheme].name }のコピー`
|
|
|
|
|
|
const appearanceFromSettings = (): ClientAppearanceSettings =>
|
|
loadClientSettings ().appearance ?? { }
|
|
|
|
|
|
let cachedUserThemeSlots: UserThemeSlotMap = { }
|
|
|
|
|
|
export const hasStoredClientThemeSelection = (): boolean => {
|
|
const appearance = appearanceFromSettings ()
|
|
return (appearance.themeMode != null
|
|
|| appearance.activeLightThemeSlotNo != null
|
|
|| appearance.activeDarkThemeSlotNo != null
|
|
|| appearance.activeThemeSlot != null
|
|
|| appearance.activeThemeId != null
|
|
|| appearance.customThemes != null
|
|
|| appearance.theme != null
|
|
|| appearance.tagColours != null)
|
|
}
|
|
|
|
|
|
const mixHexColour = (
|
|
source: string,
|
|
target: string,
|
|
ratio: number,
|
|
): string => {
|
|
const clampedRatio = Math.max (0, Math.min (1, ratio))
|
|
const src = source.slice (1)
|
|
const dst = target.slice (1)
|
|
const channels = [0, 2, 4].map (offset => {
|
|
const srcValue = parseInt (src.slice (offset, offset + 2), 16)
|
|
const dstValue = parseInt (dst.slice (offset, offset + 2), 16)
|
|
const mixed = Math.round (srcValue + (dstValue - srcValue) * clampedRatio)
|
|
return mixed.toString (16).padStart (2, '0')
|
|
})
|
|
|
|
return `#${ channels.join ('') }`
|
|
}
|
|
|
|
|
|
// Legacy appearance-model compatibility.
|
|
// The current settings UI uses `themeMode` plus the light/dark slot numbers.
|
|
// The helpers below remain only to read older localStorage shapes.
|
|
|
|
const legacyThemeSlotSelection = (): ThemeSlotSelection => {
|
|
const appearance = appearanceFromSettings ()
|
|
const customThemes = getClientCustomThemes ()
|
|
|
|
if (appearance.activeThemeId === 'system')
|
|
return 'system'
|
|
if (appearance.activeThemeId === 'light')
|
|
return 'builtin:light'
|
|
if (appearance.activeThemeId === 'dark')
|
|
return 'builtin:dark'
|
|
if (typeof appearance.activeThemeId === 'string'
|
|
&& customThemes[appearance.activeThemeId] != null)
|
|
return `builtin:${ customThemes[appearance.activeThemeId].baseTheme }`
|
|
if (appearance.theme === 'light')
|
|
return 'builtin:light'
|
|
if (appearance.theme === 'dark')
|
|
return 'builtin:dark'
|
|
return 'system'
|
|
}
|
|
|
|
|
|
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 =>
|
|
normaliseThemeSlotSelection (appearanceFromSettings ().activeThemeSlot)
|
|
?? legacyThemeSlotSelection ()
|
|
|
|
|
|
// Legacy write path. New code should use `setClientThemeAppearance`.
|
|
export const setClientActiveThemeSlot = (
|
|
activeThemeSlot: ThemeSlotSelection,
|
|
): void => {
|
|
updateClientSettings (settings => ({
|
|
...settings,
|
|
appearance: { ...(settings.appearance ?? { }), activeThemeSlot } }))
|
|
}
|
|
|
|
|
|
export const getClientThemeMode = (): UserSettings['theme'] =>
|
|
normaliseThemeMode (appearanceFromSettings ().themeMode) ?? legacyThemeMode ()
|
|
|
|
|
|
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 => {
|
|
setClientThemeAppearance ({
|
|
themeMode: theme,
|
|
activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (),
|
|
activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo () })
|
|
}
|
|
|
|
|
|
export const seedClientThemeMode = (theme: UserSettings['theme']): void => {
|
|
if (hasStoredClientThemeSelection ())
|
|
return
|
|
|
|
setClientThemeMode (theme)
|
|
}
|
|
|
|
|
|
const prefersDarkTheme = (): boolean =>
|
|
window.matchMedia ('(prefers-color-scheme: dark)').matches
|
|
|
|
|
|
export const resolveThemeMode = (
|
|
theme: UserSettings['theme'],
|
|
): ResolvedTheme =>
|
|
theme === 'system'
|
|
? (prefersDarkTheme () ? 'dark' : 'light')
|
|
: theme
|
|
|
|
|
|
export const defaultThemeTagColoursFor = (
|
|
theme: ResolvedTheme,
|
|
): ThemeTagColours =>
|
|
theme === 'dark'
|
|
? DEFAULT_DARK_THEME_TAG_COLOURS
|
|
: DEFAULT_LIGHT_THEME_TAG_COLOURS
|
|
|
|
|
|
const normaliseThemeTagColours = (
|
|
input: Partial<ThemeTagColours> | null | undefined,
|
|
fallback: ThemeTagColours,
|
|
): ThemeTagColours =>
|
|
CATEGORIES.reduce<ThemeTagColours> (
|
|
(colours, category) => {
|
|
const candidate = input?.[category]
|
|
colours[category] =
|
|
typeof candidate === 'string' && normaliseHexColour (candidate) != null
|
|
? candidate
|
|
: fallback[category]
|
|
return colours
|
|
},
|
|
{ ...fallback })
|
|
|
|
|
|
const normaliseThemeTokenValue = (
|
|
value: unknown,
|
|
fallback: string,
|
|
): string =>
|
|
typeof value === 'string' && value !== '' ? value : fallback
|
|
|
|
|
|
const cssColourFromThemeToken = (
|
|
value: unknown,
|
|
fallback: string,
|
|
): string => {
|
|
if (typeof value !== 'string' || value === '')
|
|
return fallback
|
|
|
|
const normalisedHex = normaliseHexColour (value)
|
|
if (normalisedHex != null)
|
|
return normalisedHex
|
|
|
|
return (/^-?\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/.test (value)
|
|
? `hsl(${ value })`
|
|
: fallback)
|
|
}
|
|
|
|
|
|
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 =
|
|
rawTokens && typeof rawTokens === 'object'
|
|
? rawTokens as Partial<ThemeTokens>
|
|
: { }
|
|
const legacyTopNavTokens =
|
|
rawTokens && typeof rawTokens === 'object'
|
|
? 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 topNavMenuLink = cssColourFromThemeToken (
|
|
link,
|
|
fallback.topNavMenuLink)
|
|
const resolvedTopNav = deriveTopNavTokens (
|
|
baseTheme,
|
|
{ rootBackgroundMobile: topNavRootBackgroundMobile,
|
|
rootBackgroundDesktop: topNavRootBackgroundDesktop,
|
|
activeBackground: topNavActiveBackground,
|
|
brandLink: topNavBrandLink },
|
|
topNavMenuLink)
|
|
|
|
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),
|
|
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 normaliseTopNavColourValue = (
|
|
value: unknown,
|
|
fallback: string,
|
|
): string => cssColourFromThemeToken (value, fallback)
|
|
|
|
|
|
const normaliseThemeTokens = (
|
|
rawTokens: unknown,
|
|
baseTheme: BuiltinThemeId,
|
|
): ThemeTokens => {
|
|
const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS
|
|
const tokens =
|
|
rawTokens && typeof rawTokens === 'object'
|
|
? rawTokens as Partial<ThemeTokens>
|
|
: { }
|
|
const legacyTopNavTokens =
|
|
rawTokens && typeof rawTokens === 'object'
|
|
? 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)
|
|
|
|
return buildThemeTokens (
|
|
baseTheme,
|
|
{ ...tokens,
|
|
background,
|
|
foreground,
|
|
card,
|
|
muted,
|
|
primary,
|
|
accent,
|
|
border,
|
|
link,
|
|
topNavRootBackgroundMobile:
|
|
tokens.topNavRootBackgroundMobile
|
|
?? legacyTopNavTokens.topNavRootBackgroundMobile
|
|
?? legacyTopNavTokens.topNavBackground,
|
|
topNavRootBackgroundDesktop:
|
|
tokens.topNavRootBackgroundDesktop
|
|
?? legacyTopNavTokens.topNavRootBackgroundDesktop
|
|
?? legacyTopNavTokens.topNavBackground,
|
|
topNavActiveBackground:
|
|
tokens.topNavActiveBackground
|
|
?? legacyTopNavTokens.topNavActiveBackground
|
|
?? legacyTopNavTokens.topNavHighlightBackground,
|
|
topNavBrandLink:
|
|
tokens.topNavBrandLink
|
|
?? legacyTopNavTokens.topNavBrandLink
|
|
?? legacyTopNavTokens.topNavLink,
|
|
topNavMenuLink:
|
|
tokens.topNavMenuLink
|
|
?? legacyTopNavTokens.topNavMenuLink
|
|
?? link,
|
|
tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours) })
|
|
}
|
|
|
|
|
|
export const getUserThemeSlotSelection = (
|
|
baseTheme: BuiltinThemeId,
|
|
slotNo: UserThemeSlotNo,
|
|
): UserThemeSlotSelection => `user:${ baseTheme }:${ slotNo }`
|
|
|
|
|
|
const isBuiltinThemeSlotSelection = (
|
|
value: string,
|
|
): value is `builtin:${ BuiltinThemeId }` =>
|
|
value === 'builtin:light' || value === 'builtin:dark'
|
|
|
|
|
|
export const isUserThemeSlotSelection = (
|
|
value: unknown,
|
|
): value is UserThemeSlotSelection =>
|
|
typeof value === 'string'
|
|
&& USER_THEME_SLOT_SELECTIONS.includes (value as UserThemeSlotSelection)
|
|
|
|
|
|
const normaliseThemeSlotSelection = (
|
|
value: unknown,
|
|
): ThemeSlotSelection | null => {
|
|
if (value === 'system')
|
|
return 'system'
|
|
if (typeof value === 'string' && isBuiltinThemeSlotSelection (value))
|
|
return value
|
|
if (isUserThemeSlotSelection (value))
|
|
return value
|
|
return null
|
|
}
|
|
|
|
|
|
const baseThemeOfSelection = (
|
|
selection: ThemeSlotSelection,
|
|
): BuiltinThemeId | null => {
|
|
if (selection === 'system')
|
|
return null
|
|
if (isBuiltinThemeSlotSelection (selection))
|
|
return selection.slice ('builtin:'.length) as BuiltinThemeId
|
|
|
|
return selection.split (':')[1] as BuiltinThemeId
|
|
}
|
|
|
|
|
|
const slotNoOfSelection = (
|
|
selection: UserThemeSlotSelection,
|
|
): UserThemeSlotNo => Number (selection.split (':')[2]) as UserThemeSlotNo
|
|
|
|
|
|
export const getDefaultUserThemeSlot = (
|
|
baseTheme: BuiltinThemeId,
|
|
slotNo: UserThemeSlotNo,
|
|
): UserThemeSlot => ({
|
|
baseTheme,
|
|
slotNo,
|
|
tokens: normaliseThemeTokens ({ }, baseTheme) })
|
|
|
|
|
|
const normaliseUserThemeSlot = (
|
|
rawSlot: unknown,
|
|
): UserThemeSlot | null => {
|
|
if (!(rawSlot) || typeof rawSlot !== 'object')
|
|
return null
|
|
|
|
const slot = rawSlot as Partial<UserThemeSlot>
|
|
const baseTheme = slot.baseTheme
|
|
const slotNo = slot.slotNo
|
|
|
|
if (baseTheme == null
|
|
|| !(isBuiltinThemeId (baseTheme))
|
|
|| slotNo == null
|
|
|| !(USER_THEME_SLOT_NOS.includes (slotNo as UserThemeSlotNo)))
|
|
return null
|
|
|
|
return {
|
|
baseTheme,
|
|
slotNo: slotNo as UserThemeSlotNo,
|
|
tokens: normaliseThemeTokens (slot.tokens, baseTheme),
|
|
createdAt: typeof slot.createdAt === 'string' ? slot.createdAt : undefined,
|
|
updatedAt: typeof slot.updatedAt === 'string' ? slot.updatedAt : undefined }
|
|
}
|
|
|
|
|
|
export const normaliseUserThemeSlots = (
|
|
slots: UserThemeSlot[],
|
|
): UserThemeSlotMap =>
|
|
slots.reduce<UserThemeSlotMap> (
|
|
(map, slot) => {
|
|
map[getUserThemeSlotSelection (slot.baseTheme, slot.slotNo)] = slot
|
|
return map
|
|
},
|
|
{ })
|
|
|
|
|
|
const normaliseCustomTheme = (
|
|
rawTheme: unknown,
|
|
): CustomClientTheme | null => {
|
|
if (!(rawTheme) || typeof rawTheme !== 'object')
|
|
return null
|
|
|
|
const theme = rawTheme as Record<string, unknown>
|
|
const id = typeof theme.id === 'string' ? theme.id : null
|
|
const name = typeof theme.name === 'string' ? theme.name : null
|
|
const baseTheme = typeof theme.baseTheme === 'string' ? theme.baseTheme : null
|
|
if (id == null
|
|
|| !(isCustomThemeId (id))
|
|
|| name == null
|
|
|| baseTheme == null
|
|
|| !(isBuiltinThemeId (baseTheme)))
|
|
return null
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
builtin: false,
|
|
baseTheme,
|
|
tokens: normaliseThemeTokens (
|
|
{ ...(theme.tokens && typeof theme.tokens === 'object'
|
|
? theme.tokens as Partial<ThemeTokens>
|
|
: { }),
|
|
tagColours: (
|
|
(theme.tokens
|
|
&& typeof theme.tokens === 'object'
|
|
&& 'tagColours' in theme.tokens)
|
|
? (theme.tokens as { tagColours?: Partial<ThemeTagColours> }).tagColours
|
|
: theme.tagColours as Partial<ThemeTagColours> | undefined) },
|
|
baseTheme) }
|
|
}
|
|
|
|
|
|
export const getClientCustomThemes = (): Record<string, CustomClientTheme> => {
|
|
const appearance = appearanceFromSettings ()
|
|
const customThemes = appearance.customThemes
|
|
|
|
if (customThemes && typeof customThemes === 'object')
|
|
{
|
|
return Object.entries (customThemes).reduce<Record<string, CustomClientTheme>> (
|
|
(themes, [key, rawTheme]) => {
|
|
const theme = normaliseCustomTheme (rawTheme)
|
|
if (theme != null)
|
|
themes[key] = theme
|
|
return themes
|
|
},
|
|
{ })
|
|
}
|
|
|
|
const legacyTagColours = appearance.tagColours
|
|
if (!(legacyTagColours) || Object.keys (legacyTagColours).length === 0)
|
|
return { }
|
|
|
|
const baseTheme = resolveThemeMode (appearance.theme ?? DEFAULT_USER_SETTINGS.theme)
|
|
return {
|
|
[LEGACY_MIGRATED_THEME_ID]: {
|
|
id: LEGACY_MIGRATED_THEME_ID,
|
|
name: themeCopyName (baseTheme),
|
|
builtin: false,
|
|
baseTheme,
|
|
tokens: {
|
|
...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS),
|
|
tagColours: normaliseThemeTagColours (
|
|
legacyTagColours,
|
|
defaultThemeTagColoursFor (baseTheme)) } } }
|
|
}
|
|
|
|
|
|
export const fetchUserThemeSlots = async (): Promise<UserThemeSlot[]> => {
|
|
const raw = await apiGet<Array<{
|
|
baseTheme: BuiltinThemeId
|
|
slotNo: UserThemeSlotNo
|
|
tokens: ThemeTokens
|
|
createdAt: string
|
|
updatedAt: string }>> ('/users/theme_slots')
|
|
|
|
return (raw
|
|
.map (slot => normaliseUserThemeSlot (slot))
|
|
.filter ((slot): slot is UserThemeSlot => slot != null))
|
|
}
|
|
|
|
|
|
export const upsertUserThemeSlot = async (
|
|
baseTheme: BuiltinThemeId,
|
|
slotNo: UserThemeSlotNo,
|
|
tokens: ThemeTokens,
|
|
): Promise<UserThemeSlot> => {
|
|
const raw = await apiPut<{
|
|
baseTheme: BuiltinThemeId
|
|
slotNo: UserThemeSlotNo
|
|
tokens: ThemeTokens
|
|
createdAt: string
|
|
updatedAt: string }> (`/users/theme_slots/${ baseTheme }/${ slotNo }`, { tokens })
|
|
const slot = normaliseUserThemeSlot (raw)
|
|
|
|
if (slot == null)
|
|
throw new Error ('theme slot response is invalid')
|
|
|
|
return slot
|
|
}
|
|
|
|
|
|
export const setCachedUserThemeSlots = (themeSlots: UserThemeSlotMap): void => {
|
|
cachedUserThemeSlots = themeSlots
|
|
}
|
|
|
|
|
|
export const getCachedUserThemeSlots = (): UserThemeSlotMap =>
|
|
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 = (
|
|
selection: ThemeSlotSelection,
|
|
themeSlots: UserThemeSlotMap,
|
|
): ThemeSlotSelection =>
|
|
isUserThemeSlotSelection (selection) && themeSlots[selection] == null
|
|
? 'system'
|
|
: selection
|
|
|
|
|
|
export const getClientActiveThemeId = (): ActiveThemeId => {
|
|
const appearance = appearanceFromSettings ()
|
|
const customThemes = getClientCustomThemes ()
|
|
|
|
if (typeof appearance.activeThemeId === 'string'
|
|
&& (appearance.activeThemeId === 'system'
|
|
|| isBuiltinThemeId (appearance.activeThemeId)
|
|
|| customThemes[appearance.activeThemeId] != null))
|
|
return appearance.activeThemeId
|
|
|
|
if (Object.keys (customThemes).length > 0
|
|
&& appearance.tagColours != null
|
|
&& Object.keys (appearance.tagColours).length > 0)
|
|
return LEGACY_MIGRATED_THEME_ID
|
|
|
|
if (typeof appearance.theme === 'string'
|
|
&& (appearance.theme === 'system'
|
|
|| appearance.theme === 'light'
|
|
|| appearance.theme === 'dark'))
|
|
return appearance.theme
|
|
|
|
return DEFAULT_USER_SETTINGS.theme
|
|
}
|
|
|
|
|
|
export const getThemeById = (
|
|
themeId: ThemeId,
|
|
customThemes: Record<string, CustomClientTheme>,
|
|
): ClientTheme =>
|
|
isBuiltinThemeId (themeId)
|
|
? BUILTIN_THEMES[themeId]
|
|
: (customThemes[themeId] ?? BUILTIN_THEMES.light)
|
|
|
|
|
|
export const getResolvedThemeFromSelection = (
|
|
activeThemeId: ActiveThemeId,
|
|
customThemes: Record<string, CustomClientTheme>,
|
|
): ClientTheme =>
|
|
activeThemeId === 'system'
|
|
? BUILTIN_THEMES[resolveThemeMode ('system')]
|
|
: getThemeById (activeThemeId, customThemes)
|
|
|
|
|
|
export const deriveUserThemeModeFromSelection = (
|
|
activeThemeId: ActiveThemeId,
|
|
customThemes: Record<string, CustomClientTheme>,
|
|
): UserSettings['theme'] => {
|
|
if (activeThemeId === 'system')
|
|
return 'system'
|
|
|
|
return getThemeById (activeThemeId, customThemes).baseTheme
|
|
}
|
|
|
|
|
|
export const resolveThemeFromSlotSelection = (
|
|
selection: ThemeSlotSelection,
|
|
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
|
|
): ResolvedThemeSelection => {
|
|
if (selection === 'system')
|
|
{
|
|
const baseTheme = resolveThemeMode ('system')
|
|
return {
|
|
selection,
|
|
baseTheme,
|
|
builtin: true,
|
|
tokens: BUILTIN_THEMES[baseTheme].tokens }
|
|
}
|
|
|
|
if (isBuiltinThemeSlotSelection (selection))
|
|
{
|
|
const baseTheme = selection.slice ('builtin:'.length) as BuiltinThemeId
|
|
return {
|
|
selection,
|
|
baseTheme,
|
|
builtin: true,
|
|
tokens: BUILTIN_THEMES[baseTheme].tokens }
|
|
}
|
|
|
|
const baseTheme = baseThemeOfSelection (selection) ?? 'light'
|
|
return {
|
|
selection,
|
|
baseTheme,
|
|
builtin: false,
|
|
tokens: (themeSlots[selection]?.tokens
|
|
?? getDefaultUserThemeSlot (baseTheme, slotNoOfSelection (selection)).tokens) }
|
|
}
|
|
|
|
|
|
export const deriveUserThemeModeFromSlotSelection = (
|
|
selection: ThemeSlotSelection,
|
|
): UserSettings['theme'] =>
|
|
selection === 'system'
|
|
? 'system'
|
|
: (baseThemeOfSelection (selection) ?? DEFAULT_USER_SETTINGS.theme)
|
|
|
|
|
|
export const createCustomThemeFromBase = (
|
|
baseTheme: BuiltinThemeId,
|
|
tokens?: ThemeTokens,
|
|
): CustomClientTheme => {
|
|
const id = createCustomThemeId ()
|
|
return {
|
|
id,
|
|
name: themeCopyName (baseTheme),
|
|
builtin: false,
|
|
baseTheme,
|
|
tokens: tokens ?? { ...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS) } }
|
|
}
|
|
|
|
|
|
export const getClientThemeTagColours = (
|
|
activeThemeId: ActiveThemeId = getClientActiveThemeId (),
|
|
customThemes: Record<string, CustomClientTheme> = getClientCustomThemes (),
|
|
): ThemeTagColours =>
|
|
({ ...getResolvedThemeFromSelection (activeThemeId, customThemes).tokens.tagColours })
|
|
|
|
|
|
export const setClientAppearanceThemes = (
|
|
activeThemeId: ActiveThemeId,
|
|
customThemes: Record<string, CustomClientTheme>,
|
|
): void => {
|
|
updateClientSettings (settings => ({
|
|
...settings,
|
|
appearance: {
|
|
...(settings.appearance ?? { }),
|
|
activeThemeId,
|
|
customThemes,
|
|
theme: undefined,
|
|
tagColours: undefined } }))
|
|
}
|
|
|
|
|
|
export const setClientThemeTagColours = (
|
|
tagColours: ThemeTagColours,
|
|
activeThemeId: ActiveThemeId = getClientActiveThemeId (),
|
|
customThemes: Record<string, CustomClientTheme> = getClientCustomThemes (),
|
|
): void => {
|
|
if (activeThemeId === 'system' || isBuiltinThemeId (activeThemeId))
|
|
return
|
|
|
|
setClientAppearanceThemes (
|
|
activeThemeId,
|
|
{ ...customThemes,
|
|
[activeThemeId]: {
|
|
...customThemes[activeThemeId],
|
|
tokens: { ...customThemes[activeThemeId].tokens, tagColours } } })
|
|
}
|
|
|
|
|
|
export const applyThemeMode = (theme: UserSettings['theme']): void => {
|
|
const root = document.documentElement
|
|
const resolvedTheme = resolveThemeMode (theme)
|
|
|
|
root.classList.toggle ('dark', resolvedTheme === 'dark')
|
|
root.style.colorScheme = resolvedTheme
|
|
}
|
|
|
|
|
|
export const applyThemeTagColours = (tagColours: ThemeTagColours): void => {
|
|
const root = document.documentElement
|
|
|
|
CATEGORIES.forEach (category => {
|
|
const colour = tagColours[category]
|
|
root.style.setProperty (`--tag-colour-${ category }`, colour)
|
|
root.style.setProperty (
|
|
`--tag-colour-${ category }-hover`,
|
|
mixHexColour (colour, '#ffffff', .18))
|
|
})
|
|
}
|
|
|
|
|
|
export const applyThemeTokens = (tokens: ThemeTokens): void => {
|
|
const root = document.documentElement
|
|
|
|
root.style.setProperty ('--background', tokens.background)
|
|
root.style.setProperty ('--foreground', tokens.foreground)
|
|
root.style.setProperty ('--card', tokens.card)
|
|
root.style.setProperty ('--card-foreground', tokens.cardForeground)
|
|
root.style.setProperty ('--popover', tokens.popover)
|
|
root.style.setProperty ('--popover-foreground', tokens.popoverForeground)
|
|
root.style.setProperty ('--primary', tokens.primary)
|
|
root.style.setProperty ('--primary-foreground', tokens.primaryForeground)
|
|
root.style.setProperty ('--secondary', tokens.secondary)
|
|
root.style.setProperty ('--secondary-foreground', tokens.secondaryForeground)
|
|
root.style.setProperty ('--muted', tokens.muted)
|
|
root.style.setProperty ('--muted-foreground', tokens.mutedForeground)
|
|
root.style.setProperty ('--accent', tokens.accent)
|
|
root.style.setProperty ('--accent-foreground', tokens.accentForeground)
|
|
root.style.setProperty ('--destructive', tokens.destructive)
|
|
root.style.setProperty ('--destructive-foreground', tokens.destructiveForeground)
|
|
root.style.setProperty ('--border', tokens.border)
|
|
root.style.setProperty ('--input', tokens.input)
|
|
root.style.setProperty ('--ring', tokens.ring)
|
|
root.style.setProperty ('--theme-link', tokens.link)
|
|
root.style.setProperty ('--top-nav-root-bg-mobile', tokens.topNavRootBackgroundMobile)
|
|
root.style.setProperty ('--top-nav-root-bg-desktop', tokens.topNavRootBackgroundDesktop)
|
|
root.style.setProperty ('--top-nav-active-bg', tokens.topNavActiveBackground)
|
|
root.style.setProperty ('--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)
|
|
root.style.setProperty ('--top-nav-brand-link', tokens.topNavBrandLink)
|
|
root.style.setProperty ('--top-nav-menu-link', tokens.topNavMenuLink)
|
|
|
|
applyThemeTagColours (tokens.tagColours)
|
|
}
|
|
|
|
|
|
export const applyThemeSelection = (
|
|
activeThemeSlot: ThemeSlotSelection,
|
|
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
|
|
): void => {
|
|
const theme = resolveThemeFromSlotSelection (activeThemeSlot, themeSlots)
|
|
applyThemeMode (theme.baseTheme)
|
|
applyThemeTokens (theme.tokens)
|
|
}
|
|
|
|
|
|
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 = (
|
|
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
|
|
): void => {
|
|
applyThemeAppearanceState ({
|
|
themeMode: getClientThemeMode (),
|
|
activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (),
|
|
activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo () }, themeSlots)
|
|
}
|
|
|
|
|
|
export const getClientListLimit = (
|
|
listKey: ClientListKey,
|
|
): ClientListLimit | null => {
|
|
const value = loadClientSettings ().lists?.[listKey]?.limit
|
|
return validListLimit (value)
|
|
}
|
|
|
|
|
|
export const getClientListOrder = <T extends string>(
|
|
listKey: ClientListKey,
|
|
): T | null => {
|
|
const value = loadClientSettings ().lists?.[listKey]?.order
|
|
return typeof value === 'string' ? value as T : null
|
|
}
|
|
|
|
|
|
export const setClientListSettings = (
|
|
listKey: ClientListKey,
|
|
{ limit, order }: { limit?: ClientListLimit
|
|
order?: string },
|
|
): void => {
|
|
updateClientSettings (settings => ({
|
|
...settings,
|
|
lists: {
|
|
...(settings.lists ?? { }),
|
|
[listKey]: {
|
|
...(settings.lists?.[listKey] ?? { }),
|
|
...(limit == null ? { } : { limit }),
|
|
...(order == null ? { } : { order }) } } }))
|
|
}
|
|
|
|
|
|
const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => {
|
|
const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY)
|
|
return ((value === 'threeColumns'
|
|
|| value === 'tagsBottom'
|
|
|| value === 'commentsBottom')
|
|
? value
|
|
: null)
|
|
}
|
|
|
|
|
|
const legacyTheatreTagFlow = (): TheatreTagFlow | null => {
|
|
const value = localStorage.getItem (LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY)
|
|
return (value === 'vertical' || value === 'horizontal') ? value : null
|
|
}
|
|
|
|
|
|
const legacyGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode | null => {
|
|
const value = localStorage.getItem (LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY)
|
|
return (value === 'on' || value === 'calm' || value === 'off') ? value : null
|
|
}
|
|
|
|
|
|
export const getClientTheatreLayoutMode = (): TheatreLayoutMode =>
|
|
loadClientSettings ().theatre?.layoutMode
|
|
?? legacyTheatreLayoutMode ()
|
|
?? 'threeColumns'
|
|
|
|
|
|
export const setClientTheatreLayoutMode = (layoutMode: TheatreLayoutMode): void => {
|
|
updateClientSettings (settings => ({
|
|
...settings,
|
|
theatre: { ...(settings.theatre ?? { }), layoutMode } }))
|
|
}
|
|
|
|
|
|
export const getClientTheatreTagFlow = (): TheatreTagFlow =>
|
|
loadClientSettings ().theatre?.tagFlow
|
|
?? legacyTheatreTagFlow ()
|
|
?? 'vertical'
|
|
|
|
|
|
export const setClientTheatreTagFlow = (tagFlow: TheatreTagFlow): void => {
|
|
updateClientSettings (settings => ({
|
|
...settings,
|
|
theatre: { ...(settings.theatre ?? { }), tagFlow } }))
|
|
}
|
|
|
|
|
|
export const getClientGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode =>
|
|
loadClientSettings ().gekanator?.backgroundMotion
|
|
?? legacyGekanatorBackgroundMotion ()
|
|
?? 'on'
|
|
|
|
|
|
export const setClientGekanatorBackgroundMotion = (
|
|
backgroundMotion: GekanatorBackgroundMotionMode,
|
|
): void => {
|
|
updateClientSettings (settings => ({
|
|
...settings,
|
|
gekanator: { ...(settings.gekanator ?? { }), backgroundMotion } }))
|
|
}
|
|
|
|
|
|
export const getClientPaneWidthPx = (
|
|
paneKey: string,
|
|
breakpoint: ClientPaneBreakpoint,
|
|
): number | null => {
|
|
const value = loadClientSettings ().panes?.[paneKey]?.widthPxByBreakpoint?.[breakpoint]
|
|
return typeof value === 'number' ? value : null
|
|
}
|
|
|
|
|
|
export const setClientPaneWidthPx = (
|
|
paneKey: string,
|
|
breakpoint: ClientPaneBreakpoint,
|
|
widthPx: number,
|
|
): void => {
|
|
updateClientSettings (settings => ({
|
|
...settings,
|
|
panes: {
|
|
...(settings.panes ?? { }),
|
|
[paneKey]: {
|
|
...(settings.panes?.[paneKey] ?? { }),
|
|
widthPxByBreakpoint: {
|
|
...(settings.panes?.[paneKey]?.widthPxByBreakpoint ?? { }),
|
|
[breakpoint]: widthPx } } } }))
|
|
}
|
|
|
|
|
|
export const setClientPaneCollapsed = (
|
|
paneKey: string,
|
|
collapsed: boolean,
|
|
): void => {
|
|
updateClientSettings (settings => ({
|
|
...settings,
|
|
panes: {
|
|
...(settings.panes ?? { }),
|
|
[paneKey]: {
|
|
...(settings.panes?.[paneKey] ?? { }),
|
|
collapsed } } }))
|
|
}
|