このコミットが含まれているのは:
2026-07-05 18:21:39 +09:00
コミット 1b6cac43c2
14個のファイルの変更1540行の追加397行の削除
+26 -1
ファイルの表示
@@ -13,8 +13,15 @@ import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import { applyClientAppearance,
fetchUserThemeSlots,
fetchUserSettings,
getClientThemeMode,
getClientActiveThemeSlot,
hasStoredClientThemeSelection,
normaliseThemeSlotSelectionAgainstSlots,
normaliseUserThemeSlots,
setCachedUserThemeSlots,
setClientActiveThemeSlot,
seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
@@ -177,8 +184,26 @@ const App: FC = () => {
void (async () => {
try
{
const settings = await fetchUserSettings ()
const [settings, themeSlots] = await Promise.all ([
fetchUserSettings (),
fetchUserThemeSlots (),
])
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
const initialSelection = (
hasStoredClientThemeSelection ()
? getClientActiveThemeSlot ()
: (settings.theme === 'system'
? 'system'
: `builtin:${ settings.theme }`)
)
const nextSelection = normaliseThemeSlotSelectionAgainstSlots (
initialSelection,
storedThemeSlots,
)
setCachedUserThemeSlots (storedThemeSlots)
seedClientThemeMode (settings.theme)
if (nextSelection !== initialSelection)
setClientActiveThemeSlot (nextSelection)
applyClientAppearance ()
}
catch
+7
ファイルの表示
@@ -5,6 +5,7 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -33,6 +34,8 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const url = useMemo (() => {
const path = (typeof to === 'string') ? to : createPath (to)
return (new URL (path, location.origin)).toString ()
@@ -54,11 +57,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => {
onMouseEnter?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch ()
}
const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => {
onTouchStart?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch ()
}
+54 -2
ファイルの表示
@@ -21,6 +21,7 @@ import { toast } from '@/components/ui/use-toast'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core'
@@ -29,6 +30,9 @@ import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, TagWithSections } from '@/types'
type TagByCategory = { [key in Category]: TagWithSections[] }
type FlatTagRow = {
tag: TagWithSections
parentTagId?: number }
const renderTagTree = (
@@ -61,6 +65,33 @@ const renderTagTree = (
}
const flattenTagTree = (
tags: TagWithSections[],
): FlatTagRow[] => {
const seen = new Set<number> ()
const rows: FlatTagRow[] = []
const visit = (
tag: TagWithSections,
parentTagId?: number,
) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
rows.push ({ tag, parentTagId })
for (const child of tag.children ?? [])
visit (child, tag.id)
}
for (const tag of tags)
visit (tag)
return rows.sort ((rowA, rowB) => rowA.tag.name < rowB.tag.name ? -1 : 1)
}
const isDescendant = (
root: TagWithSections,
targetId: number,
@@ -156,6 +187,8 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
sp = Boolean (sp)
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped'
const baseTags = useMemo<TagByCategory> (() => {
const tagsTmp = { } as TagByCategory
@@ -321,8 +354,27 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
</SubsectionTitle>
<ul>
{(tags[cat] ?? []).flatMap (tag => (
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))}
{(tagRelationDisplay === 'grouped'
? (tags[cat] ?? []).flatMap (tag =>
renderTagTree (
tag,
0,
`cat-${ cat }`,
suppressClickRef,
undefined,
sp,
),
)
: flattenTagTree (tags[cat] ?? []).map (row => (
<li key={`flat-${ cat }-${ row.tag.id }`} className="mb-1">
<DraggableDroppableTagRow
tag={row.tag}
nestLevel={0}
pathKey={`flat-${ cat }-${ row.tag.id }`}
parentTagId={row.parentTagId}
suppressClickRef={suppressClickRef}
sp={sp}/>
</li>)))}
<DropSlot cat={cat}/>
</ul>
</div>))}
+17 -20
ファイルの表示
@@ -209,12 +209,11 @@ const TopNav: FC<Props> = ({ user }) => {
return (
<>
<nav className="px-3 flex justify-between items-center w-full
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
border-b top-nav-themed">
<div className="flex items-center gap-2 h-12">
<PrefetchLink
to="/posts"
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
className="mx-4 text-xl font-bold"
onClick={() => {
scroll (0, 0)
}}>
@@ -224,7 +223,7 @@ const TopNav: FC<Props> = ({ user }) => {
<div ref={navRef} className="relative hidden md:flex h-12 items-center">
<div aria-hidden
className={cn ('absolute inset-y-0 h-12',
'bg-yellow-200 dark:bg-red-950',
'top-nav-themed-active',
highlightTransitionClass)}
style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`,
@@ -247,7 +246,7 @@ const TopNav: FC<Props> = ({ user }) => {
itemsRef.current[i] = el
}}
className={cn ('relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
(i === openItemIdx) && 'top-nav-themed-active font-bold')}>
{item.name}
</PrefetchLink>
</motion.div>))}
@@ -262,7 +261,7 @@ const TopNav: FC<Props> = ({ user }) => {
measure (-1)
}}
className={cn ('relative z-10 flex h-full items-center px-5',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
(openItemIdx < 0 || moreVsbl) && 'top-nav-themed-active font-bold')}>
&raquo;
</PrefetchLink>
</div>
@@ -272,9 +271,7 @@ const TopNav: FC<Props> = ({ user }) => {
<button
type="button"
className="md:hidden ml-auto border-0 bg-transparent pr-4
text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
className="md:hidden ml-auto border-0 bg-transparent pr-4"
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
@@ -288,7 +285,7 @@ const TopNav: FC<Props> = ({ user }) => {
{...(animationsOff ? { }
: { layout: true })}
className="relative z-20 hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950"
top-nav-themed-submenu"
animate={{ height: submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
@@ -429,8 +426,8 @@ const TopNav: FC<Props> = ({ user }) => {
? (
menuOpen && (
<div
className={cn ('flex flex-col md:hidden',
'bg-yellow-200 dark:bg-red-975 items-start')}>
className={cn ('top-nav-themed top-nav-themed-submenu flex flex-col md:hidden',
'items-start')}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
@@ -438,7 +435,7 @@ const TopNav: FC<Props> = ({ user }) => {
to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
&& 'top-nav-themed-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
@@ -450,7 +447,7 @@ const TopNav: FC<Props> = ({ user }) => {
</PrefetchLink>
{i === openItemIdx && (
<div className="w-full bg-yellow-50 dark:bg-red-950">
<div className="top-nav-themed-submenu w-full">
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
@@ -478,7 +475,7 @@ const TopNav: FC<Props> = ({ user }) => {
}}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
&& 'top-nav-themed-active font-bold'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
@@ -489,8 +486,8 @@ const TopNav: FC<Props> = ({ user }) => {
{menuOpen && (
<motion.div
key="spmenu"
className={cn ('flex flex-col md:hidden',
'bg-yellow-200 dark:bg-red-975 items-start')}
className={cn ('top-nav-themed top-nav-themed-submenu flex flex-col md:hidden',
'items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
@@ -506,7 +503,7 @@ const TopNav: FC<Props> = ({ user }) => {
to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
&& 'top-nav-themed-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
@@ -521,7 +518,7 @@ const TopNav: FC<Props> = ({ user }) => {
{i === openItemIdx && (
<motion.div
key={`sp-sub-${ i }`}
className="w-full bg-yellow-50 dark:bg-red-950"
className="top-nav-themed-submenu w-full"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0,
opacity: 0 },
@@ -560,7 +557,7 @@ const TopNav: FC<Props> = ({ user }) => {
}}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
&& 'top-nav-themed-active font-bold'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
+30 -2
ファイルの表示
@@ -10,6 +10,8 @@ import type { FC, ReactNode } from 'react'
import type { ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientLinkPreloadMode,
ClientTagRelationDisplayMode,
ClientThumbnailMode } from '@/lib/settings'
type Props = {
@@ -39,6 +41,14 @@ const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
{ value: 'manual', label: 'クリック時' },
{ value: 'auto', label: '自動' }]
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
{ value: 'off', label: 'しない' },
{ value: 'intent', label: 'マウスを置いたら' }]
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
{ value: 'flat', label: 'まとめない' },
{ value: 'grouped', label: 'まとめる' }]
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
{ value: 'off', label: '非表示' },
{ value: 'light', label: '軽量' },
@@ -63,8 +73,8 @@ const SegmentedControl = <T extends string,> (
'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2',
value === option.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
? 'bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/40'
: 'bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => onChange (option.value)}>
{option.label}
@@ -157,6 +167,24 @@ const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => {
onChange={value => updateDraft ('animation', value)}/>
</SettingBlock>
<SettingBlock
title="リンク先の先読み"
description="リンクにマウスを置いた時などに、移動先の情報を先に読みます。通信量や意図しない読込みが気になる場合は「しない」にできます。">
<SegmentedControl
value={draftSettings.linkPreload ?? 'intent'}
options={linkPreloadOptions}
onChange={value => updateDraft ('linkPreload', value)}/>
</SettingBlock>
<SettingBlock
title="タグの親子関係表示"
description="親子関係があるタグを、まとまりとして表示します。見た目を単純にしたい場合は「まとめない」にできます。">
<SegmentedControl
value={draftSettings.tagRelationDisplay ?? 'grouped'}
options={tagRelationDisplayOptions}
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
</SettingBlock>
<SettingBlock
title="埋め込み自動読込"
description="外部埋め込みを自動で読み込むかを切り替えます。">
+55
ファイルの表示
@@ -34,6 +34,15 @@
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--theme-link: 221.2 83.2% 53.3%;
--top-nav-background: 48 96% 89%;
--top-nav-foreground: 20 14.3% 4.1%;
--top-nav-border: 45 93% 77%;
--top-nav-link: 334 84% 57%;
--top-nav-link-hover: 334 78% 69%;
--top-nav-active-background: 48 96% 89%;
--top-nav-active-foreground: 20 14.3% 4.1%;
--top-nav-submenu-background: 48 100% 96%;
--top-nav-submenu-foreground: 20 14.3% 4.1%;
--tag-colour-deerjikist: #9f1239;
--tag-colour-deerjikist-hover: #b62a51;
@@ -78,6 +87,15 @@
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--top-nav-background: 0 49% 20%;
--top-nav-foreground: 345 100% 93%;
--top-nav-border: 0 38% 28%;
--top-nav-link: 335 82% 80%;
--top-nav-link-hover: 341 100% 92%;
--top-nav-active-background: 0 38% 28%;
--top-nav-active-foreground: 345 100% 93%;
--top-nav-submenu-background: 0 49% 20%;
--top-nav-submenu-foreground: 345 100% 93%;
}
body
@@ -197,6 +215,43 @@ body
color: var(--tag-link-hover-colour) !important;
}
.top-nav-themed
{
background: hsl(var(--top-nav-background));
color: hsl(var(--top-nav-foreground));
border-color: hsl(var(--top-nav-border));
}
.top-nav-themed a,
.top-nav-themed button
{
color: hsl(var(--top-nav-link));
}
.top-nav-themed a:hover,
.top-nav-themed button:hover
{
color: hsl(var(--top-nav-link-hover));
}
.top-nav-themed-active
{
background: hsl(var(--top-nav-active-background));
color: hsl(var(--top-nav-active-foreground));
}
.top-nav-themed a.top-nav-themed-active,
.top-nav-themed button.top-nav-themed-active
{
color: hsl(var(--top-nav-active-foreground));
}
.top-nav-themed-submenu
{
background: hsl(var(--top-nav-submenu-background));
color: hsl(var(--top-nav-submenu-foreground));
}
.tag-marquee__animated
{
position: absolute;
+497 -42
ファイルの表示
@@ -1,5 +1,5 @@
import { CATEGORIES } from '@/consts'
import { apiGet, apiPatch } from '@/lib/api'
import { apiGet, apiPatch, apiPut } from '@/lib/api'
import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts'
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
@@ -13,6 +13,12 @@ export type UserSettings = {
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 ThemeSlotSelection =
| 'system'
| `builtin:${ BuiltinThemeId }`
| UserThemeSlotSelection
export type ThemeId = BuiltinThemeId | `custom:${ string }`
export type ActiveThemeId = ThemeId | 'system'
export type ResolvedTheme = 'light' | 'dark'
@@ -37,8 +43,30 @@ export type ThemeTokens = {
input: string
ring: string
link: string
topNavBackground: string
topNavForeground: string
topNavBorder: string
topNavLink: string
topNavLinkHover: string
topNavActiveBackground: string
topNavActiveForeground: string
topNavSubmenuBackground: string
topNavSubmenuForeground: string
tagColours: ThemeTagColours
}
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 ClientTheme = {
id: ThemeId
name: string
@@ -53,6 +81,8 @@ 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'
@@ -72,6 +102,7 @@ export type ClientKeyboardSettings = {
}
export type ClientAppearanceSettings = {
activeThemeSlot?: ThemeSlotSelection
activeThemeId?: ActiveThemeId
customThemes?: Record<string, CustomClientTheme>
// Legacy fields kept for migration from the earlier appearance model.
@@ -80,6 +111,8 @@ export type ClientAppearanceSettings = {
export type ClientBehaviorSettings = {
animation?: ClientAnimationMode
linkPreload?: ClientLinkPreloadMode
tagRelationDisplay?: ClientTagRelationDisplayMode
embedAutoLoad?: ClientEmbedAutoLoadMode
thumbnailMode?: ClientThumbnailMode
}
@@ -113,6 +146,15 @@ export type ClientSettings = {
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 = [
@@ -182,6 +224,15 @@ export const LIGHT_THEME_TOKENS: ThemeTokens = {
input: '214.3 31.8% 91.4%',
ring: '222.2 84% 4.9%',
link: '221.2 83.2% 53.3%',
topNavBackground: '48 96% 89%',
topNavForeground: '20 14.3% 4.1%',
topNavBorder: '45 93% 77%',
topNavLink: '334 84% 57%',
topNavLinkHover: '334 78% 69%',
topNavActiveBackground: '48 96% 89%',
topNavActiveForeground: '20 14.3% 4.1%',
topNavSubmenuBackground: '48 100% 96%',
topNavSubmenuForeground: '20 14.3% 4.1%',
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS,
}
export const DARK_THEME_TOKENS: ThemeTokens = {
@@ -205,6 +256,15 @@ export const DARK_THEME_TOKENS: ThemeTokens = {
input: '217.2 32.6% 17.5%',
ring: '212.7 26.8% 83.9%',
link: '213.1 93.9% 67.8%',
topNavBackground: '0 49% 20%',
topNavForeground: '345 100% 93%',
topNavBorder: '0 38% 28%',
topNavLink: '335 82% 80%',
topNavLinkHover: '341 100% 92%',
topNavActiveBackground: '0 38% 28%',
topNavActiveForeground: '345 100% 93%',
topNavSubmenuBackground: '0 49% 20%',
topNavSubmenuForeground: '345 100% 93%',
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS,
}
export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
@@ -336,6 +396,18 @@ const normaliseEmbedAutoLoadMode = (value: unknown): ClientEmbedAutoLoadMode | n
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
@@ -418,6 +490,35 @@ export const setClientAnimationMode = (
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 ()
@@ -450,6 +551,8 @@ export const setClientThumbnailMode = (
export const getClientBehaviorSettings = (): ClientBehaviorSettings => ({
animation: getClientAnimationMode (),
linkPreload: getClientLinkPreloadMode (),
tagRelationDisplay: getClientTagRelationDisplayMode (),
embedAutoLoad: getClientEmbedAutoLoadMode (),
thumbnailMode: getClientThumbnailMode (),
})
@@ -463,6 +566,14 @@ export const setClientBehaviorSettings = (
normaliseAnimationMode (behavior.animation)
?? getClientAnimationMode ()
),
linkPreload: (
normaliseLinkPreloadMode (behavior.linkPreload)
?? getClientLinkPreloadMode ()
),
tagRelationDisplay: (
normaliseTagRelationDisplayMode (behavior.tagRelationDisplay)
?? getClientTagRelationDisplayMode ()
),
embedAutoLoad: (
normaliseEmbedAutoLoadMode (behavior.embedAutoLoad)
?? getClientEmbedAutoLoadMode ()
@@ -575,11 +686,14 @@ const themeCopyName = (baseTheme: BuiltinThemeId): string =>
const appearanceFromSettings = (): ClientAppearanceSettings =>
loadClientSettings ().appearance ?? { }
let cachedUserThemeSlots: UserThemeSlotMap = { }
export const hasStoredClientThemeSelection = (): boolean => {
const appearance = appearanceFromSettings ()
return (
appearance.activeThemeId != null
appearance.activeThemeSlot != null
|| appearance.activeThemeId != null
|| appearance.customThemes != null
|| appearance.theme != null
|| appearance.tagColours != null
@@ -608,37 +722,66 @@ const mixHexColour = (
}
export const getClientThemeMode = (): UserSettings['theme'] =>
(() => {
const appearance = appearanceFromSettings ()
const customThemes = getClientCustomThemes ()
const legacyThemeSlotSelection = (): ThemeSlotSelection => {
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
}) ()
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'
}
export const setClientThemeMode = (theme: UserSettings['theme']): void => {
export const getClientActiveThemeSlot = (): ThemeSlotSelection =>
normaliseThemeSlotSelection (appearanceFromSettings ().activeThemeSlot)
?? legacyThemeSlotSelection ()
export const setClientActiveThemeSlot = (
activeThemeSlot: ThemeSlotSelection,
): void => {
updateClientSettings (settings => ({
...settings,
appearance: {
...(settings.appearance ?? { }),
activeThemeId: theme,
theme,
activeThemeSlot,
},
}))
}
export const getClientThemeMode = (): UserSettings['theme'] => {
const selection = getClientActiveThemeSlot ()
if (selection === 'system')
return 'system'
return baseThemeOfSelection (selection) ?? DEFAULT_USER_SETTINGS.theme
}
export const setClientThemeMode = (theme: UserSettings['theme']): void => {
setClientActiveThemeSlot (
theme === 'system'
? 'system'
: `builtin:${ theme }`,
)
}
export const seedClientThemeMode = (theme: UserSettings['theme']): void => {
if (hasStoredClientThemeSelection ())
return
@@ -684,6 +827,207 @@ const normaliseThemeTagColours = (
)
const normaliseThemeTokenValue = (
value: unknown,
fallback: string,
): string =>
typeof value === 'string' && value !== '' ? 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>
: { }
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,
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),
topNavBackground: normaliseThemeTokenValue (
tokens.topNavBackground,
fallback.topNavBackground,
),
topNavForeground: normaliseThemeTokenValue (
tokens.topNavForeground,
fallback.topNavForeground,
),
topNavBorder: normaliseThemeTokenValue (
tokens.topNavBorder,
fallback.topNavBorder,
),
topNavLink: normaliseThemeTokenValue (
tokens.topNavLink,
fallback.topNavLink,
),
topNavLinkHover: normaliseThemeTokenValue (
tokens.topNavLinkHover,
fallback.topNavLinkHover,
),
topNavActiveBackground: normaliseThemeTokenValue (
tokens.topNavActiveBackground,
fallback.topNavActiveBackground,
),
topNavActiveForeground: normaliseThemeTokenValue (
tokens.topNavActiveForeground,
fallback.topNavActiveForeground,
),
topNavSubmenuBackground: normaliseThemeTokenValue (
tokens.topNavSubmenuBackground,
fallback.topNavSubmenuBackground,
),
topNavSubmenuForeground: normaliseThemeTokenValue (
tokens.topNavSubmenuForeground,
fallback.topNavSubmenuForeground,
),
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 => {
@@ -708,26 +1052,21 @@ const normaliseCustomTheme = (
name,
builtin: false,
baseTheme,
tokens: {
...(
baseTheme === 'dark'
? DARK_THEME_TOKENS
: LIGHT_THEME_TOKENS
),
...(theme.tokens && typeof theme.tokens === 'object'
? theme.tokens as Partial<ThemeTokens>
: { }),
tagColours: normaliseThemeTagColours (
(
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,
defaultThemeTagColoursFor (baseTheme),
),
},
? (theme.tokens as { tagColours?: Partial<ThemeTagColours> }).tagColours
: theme.tagColours as Partial<ThemeTagColours> | undefined,
},
baseTheme,
),
}
}
@@ -772,6 +1111,60 @@ export const getClientCustomThemes = (): Record<string, CustomClientTheme> => {
}
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 normaliseThemeSlotSelectionAgainstSlots = (
selection: ThemeSlotSelection,
themeSlots: UserThemeSlotMap,
): ThemeSlotSelection =>
isUserThemeSlotSelection (selection) && themeSlots[selection] == null
? 'system'
: selection
export const getClientActiveThemeId = (): ActiveThemeId => {
const appearance = appearanceFromSettings ()
const customThemes = getClientCustomThemes ()
@@ -833,6 +1226,51 @@ export const deriveUserThemeModeFromSelection = (
}
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,
@@ -947,23 +1385,40 @@ export const applyThemeTokens = (tokens: ThemeTokens): void => {
root.style.setProperty ('--input', tokens.input)
root.style.setProperty ('--ring', tokens.ring)
root.style.setProperty ('--theme-link', tokens.link)
root.style.setProperty ('--top-nav-background', tokens.topNavBackground)
root.style.setProperty ('--top-nav-foreground', tokens.topNavForeground)
root.style.setProperty ('--top-nav-border', tokens.topNavBorder)
root.style.setProperty ('--top-nav-link', tokens.topNavLink)
root.style.setProperty ('--top-nav-link-hover', tokens.topNavLinkHover)
root.style.setProperty ('--top-nav-active-background', tokens.topNavActiveBackground)
root.style.setProperty ('--top-nav-active-foreground', tokens.topNavActiveForeground)
root.style.setProperty (
'--top-nav-submenu-background',
tokens.topNavSubmenuBackground,
)
root.style.setProperty (
'--top-nav-submenu-foreground',
tokens.topNavSubmenuForeground,
)
applyThemeTagColours (tokens.tagColours)
}
export const applyThemeSelection = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
activeThemeSlot: ThemeSlotSelection,
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): void => {
const theme = getResolvedThemeFromSelection (activeThemeId, customThemes)
const theme = resolveThemeFromSlotSelection (activeThemeSlot, themeSlots)
applyThemeMode (theme.baseTheme)
applyThemeTokens (theme.tokens)
}
export const applyClientAppearance = (): void => {
applyThemeSelection (getClientActiveThemeId (), getClientCustomThemes ())
export const applyClientAppearance = (
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): void => {
applyThemeSelection (getClientActiveThemeSlot (), themeSlots)
}
ファイル差分が大きすぎるため省略します 差分を読込み