このコミットが含まれているのは:
+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 (
|
||||
|
||||
新しい課題から参照
ユーザをブロックする