このコミットが含まれているのは:
2026-07-05 01:51:07 +09:00
コミット d0ea329887
14個のファイルの変更1208行の追加645行の削除
+467 -32
ファイルの表示
@@ -1,10 +1,27 @@
import { CATEGORIES } from '@/consts'
import { apiGet, apiPatch } from '@/lib/api'
import type { FetchPostsOrder, FetchTagsOrder } from '@/types'
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
// DB-backed user settings. These are the cross-device preferences worth syncing.
export type UserSettings = {
theme: 'system' | 'light' | 'dark' }
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 ThemeId = BuiltinThemeId | `custom:${ string }`
export type ActiveThemeId = ThemeId | 'system'
export type ResolvedTheme = 'light' | 'dark'
export type ClientTheme = {
id: ThemeId
name: string
builtin: boolean
baseTheme: BuiltinThemeId
tagColours: ThemeTagColours }
export type CustomClientTheme =
ClientTheme & { builtin: false }
export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
export type TheatreTagFlow = 'vertical' | 'horizontal'
@@ -21,19 +38,32 @@ type ClientListSettings = {
limit?: ClientListLimit
order?: string }
type ClientKeyboardSettings = {
shortcutsEnabled?: boolean }
export type ClientAppearanceSettings = {
activeThemeId?: ActiveThemeId
customThemes?: Record<string, CustomClientTheme>
// Legacy fields kept for migration from the earlier appearance model.
theme?: UserSettings['theme']
tagColours?: Partial<ThemeTagColours> }
// Browser-local settings. These stay device-specific and are not synced to DB.
// 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 = {
panes?: Record<string, ClientPaneSettings>
lists?: Partial<Record<ClientListKey, ClientListSettings>>
keyboard?: ClientKeyboardSettings
theatre?: {
appearance?: ClientAppearanceSettings
panes?: Record<string, ClientPaneSettings>
lists?: Partial<Record<ClientListKey, ClientListSettings>>
theatre?: {
layoutMode?: TheatreLayoutMode
tagFlow?: TheatreTagFlow
}
gekanator?: {
gekanator?: {
backgroundMotion?: GekanatorBackgroundMotionMode
}
reducedMotion?: string
@@ -41,9 +71,6 @@ export type ClientSettings = {
thumbnailMode?: string }
export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings'
export const DEFAULT_USER_SETTINGS: UserSettings = {
theme: 'system' }
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 = [
@@ -72,21 +99,84 @@ export const TAG_LIST_ORDER_OPTIONS = [
] 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 = 'name:asc'
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 BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
light: {
id: 'light',
name: 'ライト・モード',
builtin: true,
baseTheme: 'light',
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS,
},
dark: {
id: 'dark',
name: 'ダーク・モード',
builtin: true,
baseTheme: 'dark',
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS,
},
}
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'
export const fetchUserSettings = async (): Promise<UserSettings> =>
await apiGet<UserSettings> ('/users/settings')
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> => await apiPatch<UserSettings> ('/users/settings', settings)
): 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 => {
@@ -127,6 +217,365 @@ const validListLimit = (value: unknown): ClientListLimit | null =>
value === 20 || value === 50 || value === 100 ? value : null
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 ?? { }
export const hasStoredClientThemeSelection = (): boolean => {
const appearance = appearanceFromSettings ()
return (
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 ('') }`
}
export const getClientThemeMode = (): UserSettings['theme'] =>
(() => {
const appearance = appearanceFromSettings ()
const customThemes = getClientCustomThemes ()
if (appearance.activeThemeId === 'system')
return 'system'
if (appearance.activeThemeId === 'light' || appearance.activeThemeId === 'dark')
return appearance.activeThemeId
if (
typeof appearance.activeThemeId === 'string'
&& customThemes[appearance.activeThemeId] != null
)
return customThemes[appearance.activeThemeId].baseTheme
return appearance.theme ?? DEFAULT_USER_SETTINGS.theme
}) ()
export const setClientThemeMode = (theme: UserSettings['theme']): void => {
updateClientSettings (settings => ({
...settings,
appearance: {
...(settings.appearance ?? { }),
activeThemeId: theme,
theme,
},
}))
}
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 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,
tagColours: normaliseThemeTagColours (
theme.tagColours as Partial<ThemeTagColours> | undefined,
defaultThemeTagColoursFor (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,
tagColours: normaliseThemeTagColours (
legacyTagColours,
defaultThemeTagColoursFor (baseTheme),
),
},
}
}
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 createCustomThemeFromBase = (
baseTheme: BuiltinThemeId,
tagColours?: ThemeTagColours,
): CustomClientTheme => {
const id = `custom:${ crypto.randomUUID () }` as ThemeId
return {
id,
name: themeCopyName (baseTheme),
builtin: false,
baseTheme,
tagColours: tagColours ?? { ...defaultThemeTagColoursFor (baseTheme) },
}
}
export const getClientThemeTagColours = (
activeThemeId: ActiveThemeId = getClientActiveThemeId (),
customThemes: Record<string, CustomClientTheme> = getClientCustomThemes (),
): ThemeTagColours => {
return {
...getResolvedThemeFromSelection (activeThemeId, customThemes).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],
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 applyThemeSelection = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): void => {
const theme = getResolvedThemeFromSelection (activeThemeId, customThemes)
applyThemeMode (theme.baseTheme)
applyThemeTagColours (theme.tagColours)
}
export const applyClientAppearance = (): void => {
applyThemeSelection (getClientActiveThemeId (), getClientCustomThemes ())
}
export const getClientListLimit = (
listKey: ClientListKey,
): ClientListLimit | null => {
@@ -162,20 +611,6 @@ export const setClientListSettings = (
}
export const getClientKeyboardShortcutsEnabled = (): boolean =>
loadClientSettings ().keyboard?.shortcutsEnabled ?? true
export const setClientKeyboardShortcutsEnabled = (
enabled: boolean,
): void => {
updateClientSettings (settings => ({
...settings,
keyboard: { ...(settings.keyboard ?? { }), shortcutsEnabled: enabled },
}))
}
const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => {
const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY)
return (