ファイル
btrc-hub/frontend/src/pages/users/SettingPage.tsx
T
2026-07-06 19:34:11 +09:00

1420 行
44 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useBeforeUnload, useBlocker, useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import {
applyClientAppearance,
applyThemeAppearanceState,
fetchUserSettings,
fetchUserThemeSlots,
getClientActiveDarkThemeSlotNo,
getClientActiveLightThemeSlotNo,
getClientThemeMode,
getDefaultUserThemeSlot,
hasStoredClientThemeSelection,
getUserThemeSlotSelection,
normaliseUserThemeSlots,
resolveDisplayedTheme,
setCachedUserThemeSlots,
setClientThemeAppearance,
upsertUserThemeSlot,
USER_THEME_SLOT_SELECTIONS,
} from '@/lib/settings'
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
import type { Category, Tag, User } from '@/types'
import type {
BuiltinThemeId,
ClientThemeMode,
ClientTheme,
ThemeTokens,
ThemeTagColours,
UserThemeSlotMap,
UserThemeSlotNo,
UserThemeSlotSelection,
} from '@/lib/settings'
type Props = {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }
type UserFormField = 'name'
type SettingsFormField = 'theme'
type SettingsTab = 'account' | 'behavior' | 'theme' | 'keyboard'
type ThemeTokenKey = Exclude<keyof ThemeTokens, 'tagColours'>
type TabSpec = {
id: SettingsTab
label: string }
type AccountSectionProps = {
user: User
name: string
onNameChange: (value: string) => void
onInheritOpen: () => void
onUserCodeOpen: () => void
onUserSubmit: () => void
userBaseErrors: string[]
userFieldErrors: Partial<Record<UserFormField, string[]>> }
type ThemeModeItem = {
id: ClientThemeMode
label: string }
type ThemeSelectionItem = {
id: UserThemeSlotSelection
label: string
baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo }
type ThemeTokenFieldSpec = {
key: ThemeTokenKey
label: string }
type ThemeTokenGroupSpec = {
title: string
fields: ThemeTokenFieldSpec[] }
type ThemeSectionProps = {
draftThemeMode: ClientThemeMode
draftActiveLightThemeSlotNo: UserThemeSlotNo
draftActiveDarkThemeSlotNo: UserThemeSlotNo
editingThemeSlot: UserThemeSlotSelection
selectedTheme: ClientTheme
displayedThemeLabel: string
systemThemeLabel: string
hasUnsavedChanges: boolean
settingsBaseErrors: string[]
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
onSelectThemeMode: (themeMode: ClientThemeMode) => void
onSelectLightThemeSlotNo: (slotNo: UserThemeSlotNo) => void
onSelectDarkThemeSlotNo: (slotNo: UserThemeSlotNo) => void
onSelectEditingThemeSlot: (selection: UserThemeSlotSelection) => void
onThemeTokenChange: (key: ThemeTokenKey, value: string) => void
onTagColourChange: (category: Category, colour: string) => void
onResetThemeSlot: (selection: UserThemeSlotSelection) => void
onThemeSubmit: () => void
onThemeDiscard: () => void }
type SettingsTabDirtyState = {
dirty: boolean
discard: () => void | Promise<void> }
type ThemeDraftState = {
themeMode: ClientThemeMode
activeLightThemeSlotNo: UserThemeSlotNo
activeDarkThemeSlotNo: UserThemeSlotNo
themeSlots: UserThemeSlotMap
editingThemeSlot: UserThemeSlotSelection }
const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' },
{ id: 'behavior', label: '動作' },
{ id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' } ]
const themeModeSelections: ThemeModeItem[] = [
{ id: 'system', label: 'システム設定に従う' },
{ id: 'light', label: 'ライト' },
{ id: 'dark', label: 'ダーク' } ]
const sectionClassName =
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
const themeSelections: ThemeSelectionItem[] = [
{ id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 },
{ id: 'user:light:2', label: 'ライト 2', baseTheme: 'light', slotNo: 2 },
{ id: 'user:light:3', label: 'ライト 3', baseTheme: 'light', slotNo: 3 },
{ id: 'user:dark:1', label: 'ダーク 1', baseTheme: 'dark', slotNo: 1 },
{ id: 'user:dark:2', label: 'ダーク 2', baseTheme: 'dark', slotNo: 2 },
{ id: 'user:dark:3', label: 'ダーク 3', baseTheme: 'dark', slotNo: 3 },
]
const themeTokenGroups: ThemeTokenGroupSpec[] = [
{ title: '基本色',
fields: [
{ key: 'background', label: '背景' },
{ key: 'foreground', label: '文字' },
{ key: 'primary', label: '主色' },
{ key: 'accent', label: 'アクセント' },
] },
{ title: 'パネル',
fields: [
{ key: 'card', label: 'カード背景' },
{ key: 'muted', label: '薄い背景' },
{ key: 'border', label: '境界線' },
{ key: 'input', label: '入力欄' },
] },
{ title: 'リンク',
fields: [
{ key: 'link', label: 'リンク' },
] },
{ title: '上部ナビ',
fields: [
{ key: 'topNavRootBackgroundMobile', label: 'スマホ背景' },
{ key: 'topNavRootBackgroundDesktop', label: 'PC 背景' },
{ key: 'topNavActiveBackground', label: '選択中背景' },
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
{ key: 'topNavMobileActiveBackground', label: 'スマホ選択中背景' },
{ key: 'topNavBrandLink', label: 'ロゴ色' },
{ key: 'topNavMenuLink', label: 'メニュー文字色' },
] },
]
const previewTag = (
id: number,
name: string,
category: Category,
postCount: number,
hasWiki: boolean,
options?: { hasDeerjikists?: boolean },
): Tag => ({
id,
name,
category,
deprecatedAt: null,
aliases: [],
parents: [],
postCount,
createdAt: '',
updatedAt: '',
hasWiki,
materialId: null,
hasDeerjikists: options?.hasDeerjikists ?? false,
children: [] })
const previewTags: Record<Category, Tag> = {
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
meme: previewTag (2, 'ミーム', 'meme', 34, true),
character: previewTag (3, 'キャラクター', 'character', 56, false),
general: previewTag (4, '一般', 'general', 78, true),
material: previewTag (5, '素材', 'material', 90, false),
meta: previewTag (6, 'メタ', 'meta', 21, true),
nico: previewTag (7, 'nico:sm9', 'nico', 43, false) }
const parseTab = (search: string): SettingsTab => {
const tab = new URLSearchParams (search).get ('tab')
return (tabs.some (candidate => candidate.id === tab)
? (tab as SettingsTab)
: 'account')
}
const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }`
const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }`
const SETTINGS_PATH = '/users/settings'
const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
Object.fromEntries (
CATEGORIES.flatMap (category => [
[`--tag-colour-${ category }`, tagColours[category]],
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
const themeLabelBySelection = (selection: UserThemeSlotSelection): string =>
themeSelections.find (item => item.id === selection)?.label ?? 'ライト 1'
const ensureCompleteThemeSlots = (
themeSlots: UserThemeSlotMap,
): UserThemeSlotMap =>
USER_THEME_SLOT_SELECTIONS.reduce<UserThemeSlotMap> (
(complete, selection) => {
const [_, baseTheme, slotNo] = selection.split (':')
complete[selection] =
themeSlots[selection]
?? getDefaultUserThemeSlot (
baseTheme as BuiltinThemeId,
Number (slotNo) as UserThemeSlotNo,
)
return complete
},
{ ...themeSlots },
)
const serialiseThemeDraft = (
themeMode: ClientThemeMode,
activeLightThemeSlotNo: UserThemeSlotNo,
activeDarkThemeSlotNo: UserThemeSlotNo,
themeSlots: UserThemeSlotMap,
): string =>
JSON.stringify ({
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
selection,
tokens: themeSlots[selection]?.tokens,
})),
})
const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean =>
key.startsWith ('topNav')
const formatHslComponent = (value: number): string =>
Number.isInteger (value) ? String (value) : value.toFixed (1).replace (/\.0$/, '')
const hslTokenToHex = (token: string): string => {
const match =
token.match (
/^(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/,
)
if (match == null)
return '#000000'
const hue = Number (match[1]) / 360
const saturation = Number (match[2]) / 100
const lightness = Number (match[3]) / 100
if (saturation === 0)
{
const channel = Math.round (lightness * 255)
const hex = channel.toString (16).padStart (2, '0')
return `#${ hex }${ hex }${ hex }`
}
const hueToRgb = (
p: number,
q: number,
t: number,
): number => {
let adjusted = t
if (adjusted < 0)
adjusted += 1
if (adjusted > 1)
adjusted -= 1
if (adjusted < 1 / 6)
return p + (q - p) * 6 * adjusted
if (adjusted < 1 / 2)
return q
if (adjusted < 2 / 3)
return p + (q - p) * (2 / 3 - adjusted) * 6
return p
}
const q =
lightness < .5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation
const p = 2 * lightness - q
const red = Math.round (hueToRgb (p, q, hue + 1 / 3) * 255)
const green = Math.round (hueToRgb (p, q, hue) * 255)
const blue = Math.round (hueToRgb (p, q, hue - 1 / 3) * 255)
return `#${ [red, green, blue]
.map (channel => channel.toString (16).padStart (2, '0'))
.join ('') }`
}
const hexToHslToken = (hex: string): string => {
const red = parseInt (hex.slice (1, 3), 16) / 255
const green = parseInt (hex.slice (3, 5), 16) / 255
const blue = parseInt (hex.slice (5, 7), 16) / 255
const max = Math.max (red, green, blue)
const min = Math.min (red, green, blue)
const lightness = (max + min) / 2
if (max === min)
return `0 0% ${ formatHslComponent (lightness * 100) }%`
const delta = max - min
const saturation =
lightness > .5
? delta / (2 - max - min)
: delta / (max + min)
let hue = 0
switch (max)
{
case red:
hue = (green - blue) / delta + (green < blue ? 6 : 0)
break
case green:
hue = (blue - red) / delta + 2
break
case blue:
hue = (red - green) / delta + 4
break
}
return [
formatHslComponent (hue * 60),
`${ formatHslComponent (saturation * 100) }%`,
`${ formatHslComponent (lightness * 100) }%`,
].join (' ')
}
const themeTokenToHex = (
key: ThemeTokenKey,
value: string,
): string => {
if (isTopNavThemeTokenKey (key))
{
if (value.startsWith ('#'))
return value
const match = value.match (/^hsl\((.+)\)$/)
return match == null ? '#000000' : hslTokenToHex (match[1])
}
return hslTokenToHex (value)
}
const hexToThemeToken = (
key: ThemeTokenKey,
value: string,
): string =>
isTopNavThemeTokenKey (key) ? value : hexToHslToken (value)
const AccountSection: FC<AccountSectionProps> = (
{ name,
onInheritOpen,
onNameChange,
onUserCodeOpen,
onUserSubmit,
user,
userBaseErrors,
userFieldErrors },
) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={userBaseErrors}/>
<FormField label="表示名" messages={userFieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder="名もなきニジラー"
onChange={ev => onNameChange (ev.target.value)}/>
{!(user.name) && (
<p className="mt-1 text-sm text-red-500">
30 !!!!
</p>)}
</>)}
</FormField>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={onUserSubmit}>
</Button>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={onUserCodeOpen}
className="bg-gray-600 text-white"
disabled={!(user)}>
</Button>
<Button
type="button"
onClick={onInheritOpen}
className="bg-red-600 text-white"
disabled={!(user)}>
</Button>
</div>
</div>
</section>)
const ThemeSection: FC<ThemeSectionProps> = (
{ draftThemeMode,
draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
editingThemeSlot,
selectedTheme,
displayedThemeLabel,
systemThemeLabel,
hasUnsavedChanges,
settingsBaseErrors,
settingsFieldErrors,
onSelectThemeMode,
onSelectLightThemeSlotNo,
onSelectDarkThemeSlotNo,
onSelectEditingThemeSlot,
onThemeTokenChange,
onTagColourChange,
onResetThemeSlot,
onThemeSubmit,
onThemeDiscard },
) => {
const currentEditableSelection = editingThemeSlot
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
: {displayedThemeLabel}
</p>
{draftThemeMode === 'system' && (
<p className="text-sm text-muted-foreground">
: {systemThemeLabel}
</p>)}
<p className="text-sm text-muted-foreground">
: {themeLabelBySelection (currentEditableSelection)}
</p>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges)}
onClick={onThemeDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges)}
onClick={onThemeSubmit}>
</Button>
</div>
</div>
<FieldError messages={settingsBaseErrors}/>
<FieldError messages={settingsFieldErrors.theme ?? []}/>
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="space-y-2">
<Label></Label>
<select
className={inputClass (false)}
value={draftThemeMode}
onChange={ev =>
onSelectThemeMode (ev.target.value as ClientThemeMode)}>
{themeModeSelections.map (selection => (
<option key={selection.id} value={selection.id}>
{selection.label}
</option>))}
</select>
</div>
<div className="space-y-2">
<Label></Label>
<select
className={inputClass (false)}
value={draftActiveLightThemeSlotNo}
onChange={ev =>
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSelections
.filter (selection => selection.baseTheme === 'light')
.map (selection => (
<option key={selection.id} value={selection.slotNo}>
{selection.label}
</option>))}
</select>
</div>
<div className="space-y-2">
<Label></Label>
<select
className={inputClass (false)}
value={draftActiveDarkThemeSlotNo}
onChange={ev =>
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSelections
.filter (selection => selection.baseTheme === 'dark')
.map (selection => (
<option key={selection.id} value={selection.slotNo}>
{selection.label}
</option>))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="theme-slot-select"></Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<select
id="theme-slot-select"
className={cn (inputClass (false), 'w-full min-w-0 sm:w-64')}
value={currentEditableSelection}
onChange={ev =>
onSelectEditingThemeSlot (ev.target.value as UserThemeSlotSelection)}>
{themeSelections.map (selection => (
<option key={selection.id} value={selection.id}>
{selection.label}
</option>))}
</select>
<Button
type="button"
variant="ghost"
onClick={() => onResetThemeSlot (currentEditableSelection)}>
</Button>
</div>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p></p>
</div>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
tag={previewTags.general}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
<div className="space-y-4">
{themeTokenGroups.map (group => (
<div
key={group.title}
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<h3 className="text-sm font-semibold">{group.title}</h3>
<div className="space-y-3">
{group.fields.map (field => (
<div
key={field.key}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{field.label}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
onChange={ev =>
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/>
</div>
</div>))}
</div>
</div>))}
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
</div>
</div>
<div className="space-y-3">
{CATEGORIES.map (category => (
<div
key={category}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
tag={previewTags[category]}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={selectedTheme.tokens.tagColours[category]}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/>
</div>
</div>))}
</div>
</div>
</div>
</section>)
}
const SettingPage: FC<Props> = ({ user, setUser }) => {
const location = useLocation ()
const navigate = useNavigate ()
const dialogue = useDialogue ()
const [inheritVsbl, setInheritVsbl] = useState (false)
const [loading, setLoading] = useState (true)
const [name, setName] = useState ('')
const [settingsError, setSettingsError] = useState<string | null> (null)
const [savedThemeMode, setSavedThemeMode] =
useState<ClientThemeMode> (getClientThemeMode ())
const [draftThemeMode, setDraftThemeMode] =
useState<ClientThemeMode> (getClientThemeMode ())
const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
const [savedThemeSlots, setSavedThemeSlots] =
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
const [draftThemeSlots, setDraftThemeSlots] =
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
const [savedEditingThemeSlot, setSavedEditingThemeSlot] =
useState<UserThemeSlotSelection> ('user:light:1')
const [editingThemeSlot, setEditingThemeSlot] =
useState<UserThemeSlotSelection> ('user:light:1')
const [dirtyStates, setDirtyStates] =
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const { conflictActionIds } = useKeyboardShortcutSettings ()
const { baseErrors: userBaseErrors,
fieldErrors: userFieldErrors,
clearValidationErrors: clearUserValidationErrors,
applyValidationError: applyUserValidationError } = useValidationErrors<UserFormField> ()
const { baseErrors: settingsBaseErrors,
fieldErrors: settingsFieldErrors,
clearValidationErrors: clearSettingsValidationErrors,
applyValidationError: applySettingsValidationError } =
useValidationErrors<SettingsFormField> ()
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
const displayedThemeSelection = useMemo (
() => resolveDisplayedTheme ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
[draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots])
const selectedTheme = useMemo<ClientTheme> (
() => {
const currentSelection =
themeSelections.find (selection => selection.id === editingThemeSlot)
?? themeSelections[0]
const themeSlot =
draftThemeSlots[currentSelection.id]
?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo)
return {
id: currentSelection.baseTheme,
name: themeLabelBySelection (currentSelection.id),
builtin: false,
baseTheme: currentSelection.baseTheme,
tokens: themeSlot.tokens }
},
[draftThemeSlots, editingThemeSlot])
const hasUnsavedThemeChanges = useMemo (
() =>
serialiseThemeDraft (draftThemeMode,
draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
draftThemeSlots)
!== (serialiseThemeDraft (savedThemeMode,
savedActiveLightThemeSlotNo,
savedActiveDarkThemeSlotNo,
savedThemeSlots)),
[draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots,
editingThemeSlot,
savedActiveDarkThemeSlotNo,
savedActiveLightThemeSlotNo,
savedEditingThemeSlot,
savedThemeMode,
savedThemeSlots])
const savedName = user?.name ?? ''
const hasUnsavedAccountChanges = name !== savedName
const hasPageUnsavedChanges = Object.values (dirtyStates).some (state => state.dirty)
const applyActiveTab = (tab: SettingsTab) => {
const params = new URLSearchParams (location.search)
params.set ('tab', tab)
navigate (
`${ location.pathname }?${ params.toString () }`,
{ replace: true })
}
const updateTabDirtyState = (
tab: SettingsTab,
state: SettingsTabDirtyState,
) => {
setDirtyStates (current => {
const next = { ...current }
if (!(state.dirty))
{
delete next[tab]
return next
}
next[tab] = state
return next
})
}
const confirmDiscardChanges = async (
description: string,
cancelText: string,
confirmText: string,
): Promise<boolean> =>
dialogue.confirm ({
title: '未保存の変更があります',
description,
confirmText,
cancelText,
variant: 'danger' })
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
() => (
{ account: Boolean (userFieldErrors.name?.length),
behavior: false,
theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: conflictActionIds.size > 0 }),
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name])
const applyDraftThemeState = (
{ themeMode = draftThemeMode,
activeLightThemeSlotNo = draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo,
themeSlots = draftThemeSlots,
nextEditingThemeSlot = editingThemeSlot,
}: { themeMode?: ClientThemeMode
activeLightThemeSlotNo?: UserThemeSlotNo
activeDarkThemeSlotNo?: UserThemeSlotNo
themeSlots?: UserThemeSlotMap
nextEditingThemeSlot?: UserThemeSlotSelection },
) => {
setDraftThemeMode (themeMode)
setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
setDraftThemeSlots (themeSlots)
setEditingThemeSlot (nextEditingThemeSlot)
setCachedUserThemeSlots (themeSlots)
applyThemeAppearanceState ({
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }, themeSlots)
}
const discardAccountChanges = () => {
setName (savedName)
clearUserValidationErrors ()
}
const currentThemeDraftState = (): ThemeDraftState => ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
themeSlots: draftThemeSlots,
editingThemeSlot })
const savedThemeDraftState = (): ThemeDraftState => ({
themeMode: savedThemeMode,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
themeSlots: savedThemeSlots,
editingThemeSlot: savedEditingThemeSlot })
const discardThemeChanges = () => {
setDraftThemeMode (savedThemeMode)
setDraftActiveLightThemeSlotNo (savedActiveLightThemeSlotNo)
setDraftActiveDarkThemeSlotNo (savedActiveDarkThemeSlotNo)
setDraftThemeSlots (savedThemeSlots)
setEditingThemeSlot (savedEditingThemeSlot)
setCachedUserThemeSlots (savedThemeSlots)
setClientThemeAppearance ({
themeMode: savedThemeMode,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo })
applyClientAppearance (savedThemeSlots)
}
const discardCurrentTabChanges = async (): Promise<void> => {
const currentDirtyState = dirtyStates[activeTab]
if (currentDirtyState?.dirty)
await currentDirtyState.discard ()
setDirtyStates ({ })
}
const requestActiveTab = async (
nextTab: SettingsTab,
): Promise<void> => {
if (nextTab === activeTab)
return
const currentDirtyState = dirtyStates[activeTab]
if (!(currentDirtyState?.dirty))
{
applyActiveTab (nextTab)
return
}
const confirmed = await confirmDiscardChanges (
'このタブを離れると、保存していない変更は失われます。',
'このタブに残る',
'変更を破棄して移動')
if (!(confirmed))
return
await discardCurrentTabChanges ()
applyActiveTab (nextTab)
}
const requestThemeStateChange = async (
buildNextState: (baseState: ThemeDraftState) => ThemeDraftState,
description = 'このまま切り替えると、保存していない変更は失われます。',
cancelText = 'このまま編集する',
confirmText = '変更を破棄して切り替える',
): Promise<void> => {
const baseState =
hasUnsavedThemeChanges
? savedThemeDraftState ()
: currentThemeDraftState ()
if (hasUnsavedThemeChanges)
{
const confirmed = await confirmDiscardChanges (
description,
cancelText,
confirmText)
if (!(confirmed))
return
}
const nextState = buildNextState (baseState)
applyDraftThemeState ({
themeMode: nextState.themeMode,
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
themeSlots: nextState.themeSlots,
nextEditingThemeSlot: nextState.editingThemeSlot })
}
const routeBlocker = useBlocker (({ currentLocation, nextLocation }) =>
hasPageUnsavedChanges
&& currentLocation.pathname === SETTINGS_PATH
&& nextLocation.pathname !== currentLocation.pathname)
useEffect (() => {
if (routeBlocker.state !== 'blocked')
return
let active = true
void (async () => {
const confirmed = await confirmDiscardChanges (
'このまま移動すると、保存していない変更は失われます。',
'このページに残る',
'変更を破棄して移動',
)
if (!(active))
return
if (!(confirmed))
{
routeBlocker.reset ()
return
}
await discardCurrentTabChanges ()
routeBlocker.proceed ()
}) ()
return () => {
active = false
}
}, [routeBlocker])
useBeforeUnload (event => {
if (!(hasPageUnsavedChanges))
return
event.preventDefault ()
event.returnValue = ''
}, { capture: true })
const updateDraftThemeSlot = (
selection: UserThemeSlotSelection,
updater: (tokens: ThemeTokens) => ThemeTokens,
) => {
const currentThemeSlot =
draftThemeSlots[selection]
?? ensureCompleteThemeSlots ({ })[selection]
const nextThemeSlots = {
...draftThemeSlots,
[selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } }
applyDraftThemeState ({ themeSlots: nextThemeSlots, nextEditingThemeSlot: selection })
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const currentIndex = tabs.findIndex (tab => tab.id === activeTab)
switch (event.key)
{
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault ()
requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
break
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault ()
requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
break
case 'Home':
event.preventDefault ()
requestActiveTab (tabs[0].id)
break
case 'End':
event.preventDefault ()
requestActiveTab (tabs[tabs.length - 1].id)
break
}
}
const handleUserSubmit = async () => {
if (!(user))
return
clearUserValidationErrors ()
const formData = new FormData
formData.append ('name', name)
try
{
const data = await apiPut<User> (
`/users/${ user.id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
setUser (currentUser => ({ ...currentUser, ...data }))
setName (data.name ?? '')
toast ({ title: '表示名を更新しました.' })
}
catch (submitError)
{
applyUserValidationError (submitError)
toast ({ title: '表示名を更新できませんでした.' })
}
}
const handleThemeSubmit = async () => {
clearSettingsValidationErrors ()
try
{
let nextSavedThemeSlots = { ...savedThemeSlots }
for (const selection of USER_THEME_SLOT_SELECTIONS)
{
const draftThemeSlot = draftThemeSlots[selection]
const savedThemeSlot = savedThemeSlots[selection]
const requiresPersist =
selection === editingThemeSlot
&& savedThemeSlot?.createdAt == null
if ((JSON.stringify (draftThemeSlot?.tokens)
=== JSON.stringify (savedThemeSlot?.tokens))
&& !(requiresPersist))
continue
const persisted = await upsertUserThemeSlot (
draftThemeSlot?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId,
draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo,
draftThemeSlot?.tokens ?? getDefaultUserThemeSlot (
selection.split (':')[1] as BuiltinThemeId,
Number (selection.split (':')[2]) as UserThemeSlotNo).tokens)
nextSavedThemeSlots = { ...nextSavedThemeSlots, [selection]: persisted }
}
const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)
setSavedThemeMode (draftThemeMode)
setSavedActiveLightThemeSlotNo (draftActiveLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
setSavedThemeSlots (completeThemeSlots)
setDraftThemeSlots (completeThemeSlots)
setSavedEditingThemeSlot (editingThemeSlot)
setEditingThemeSlot (editingThemeSlot)
// Display mode and active slot numbers are browser-local state.
setClientThemeAppearance ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo })
setCachedUserThemeSlots (completeThemeSlots)
applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' })
}
catch (submitError)
{
applySettingsValidationError (submitError)
toast ({ title: 'テーマ設定を保存できませんでした.' })
}
}
const handleThemeDiscard = () => {
discardThemeChanges ()
}
useEffect (() => {
if (!user)
return
setName (user.name ?? '')
}, [user])
useEffect (() => {
if (!(user))
return
let cancelled = false
void (async () => {
setLoading (true)
try
{
const [settings, themeSlots] = await Promise.all ([fetchUserSettings (),
fetchUserThemeSlots ()])
if (cancelled)
return
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
const completeThemeSlots = ensureCompleteThemeSlots (storedThemeSlots)
const themeMode =
hasStoredClientThemeSelection ()
? getClientThemeMode ()
: settings.theme
const activeLightThemeSlotNo = getClientActiveLightThemeSlotNo ()
const activeDarkThemeSlotNo = getClientActiveDarkThemeSlotNo ()
const initialEditingThemeSlot = resolveDisplayedTheme ({
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }, completeThemeSlots).selection
setSavedThemeMode (themeMode)
setDraftThemeMode (themeMode)
setSavedActiveLightThemeSlotNo (activeLightThemeSlotNo)
setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
setSavedThemeSlots (completeThemeSlots)
setDraftThemeSlots (completeThemeSlots)
setSavedEditingThemeSlot (initialEditingThemeSlot)
setEditingThemeSlot (initialEditingThemeSlot)
setCachedUserThemeSlots (completeThemeSlots)
applyThemeAppearanceState ({ themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }, completeThemeSlots)
setSettingsError (null)
}
catch
{
if (cancelled)
return
setSettingsError ('設定の読込みに失敗しました.')
}
finally
{
if (!(cancelled))
setLoading (false)
}
}) ()
return () => {
cancelled = true
}
}, [user])
useEffect (() => {
if (userFieldErrors.name?.length)
applyActiveTab ('account')
}, [location.pathname, location.search, userFieldErrors.name])
useEffect (() => {
updateTabDirtyState ('account', {
dirty: hasUnsavedAccountChanges,
discard: discardAccountChanges })
}, [hasUnsavedAccountChanges, savedName])
useEffect (() => {
if (settingsFieldErrors.theme?.length)
applyActiveTab ('theme')
}, [location.pathname, location.search, settingsFieldErrors.theme])
useEffect (() => {
applyThemeAppearanceState ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots)
return () => {
setCachedUserThemeSlots (savedThemeSlots)
applyClientAppearance (savedThemeSlots)
}
}, [draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots,
savedThemeSlots])
const accountSectionProps: AccountSectionProps | null =
user
? { user,
name,
onNameChange: setName,
onInheritOpen: () => setInheritVsbl (true),
onUserCodeOpen: () => setUserCodeVsbl (true),
onUserSubmit: handleUserSubmit,
userBaseErrors,
userFieldErrors }
: null
useEffect (() => {
updateTabDirtyState ('theme', {
dirty: hasUnsavedThemeChanges,
discard: handleThemeDiscard })
}, [hasUnsavedThemeChanges, savedThemeSlots])
useKeyboardShortcuts ({
['settings.account']: () => {
requestActiveTab ('account')
},
['settings.behavior']: () => {
requestActiveTab ('behavior')
},
['settings.theme']: () => {
requestActiveTab ('theme')
},
['settings.keyboard']: () => {
requestActiveTab ('keyboard')
} })
const themeSectionProps: ThemeSectionProps = {
draftThemeMode,
draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
editingThemeSlot,
selectedTheme,
displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection),
systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト',
hasUnsavedChanges: hasUnsavedThemeChanges,
settingsBaseErrors,
settingsFieldErrors,
onSelectThemeMode: themeMode => {
requestThemeStateChange (baseState => ({ ...baseState, themeMode }))
},
onSelectLightThemeSlotNo: slotNo => {
requestThemeStateChange (baseState => ({
...baseState,
activeLightThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('light', slotNo) }))
},
onSelectDarkThemeSlotNo: slotNo => {
requestThemeStateChange (baseState => ({
...baseState,
activeDarkThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo) }))
},
onSelectEditingThemeSlot: selection => {
requestThemeStateChange (baseState => ({ ...baseState, editingThemeSlot: selection }))
},
onThemeTokenChange: (key, value) => {
updateDraftThemeSlot (editingThemeSlot, tokens => ({ ...tokens, [key]: value }))
},
onTagColourChange: (category, colour) => {
updateDraftThemeSlot (editingThemeSlot, tokens => ({
...tokens, tagColours: { ...tokens.tagColours, [category]: colour } }))
},
onResetThemeSlot: selection => {
const baseTheme =
draftThemeSlots[selection]?.baseTheme
?? selection.split (':')[1] as BuiltinThemeId
updateDraftThemeSlot (
selection,
() => getDefaultUserThemeSlot (
baseTheme,
(draftThemeSlots[selection]?.slotNo
?? Number (selection.split (':')[2]) as UserThemeSlotNo)).tokens)
},
onThemeSubmit: handleThemeSubmit,
onThemeDiscard: handleThemeDiscard }
return (
<MainArea>
<Helmet>
<meta name="robots" content="noindex"/>
<title> | {SITE_TITLE}</title>
</Helmet>
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
<PageTitle></PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/>
<div
role="tablist"
aria-label="設定区分"
aria-orientation="horizontal"
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
{tabs.map (tab => (
<button
key={tab.id}
id={tabButtonId (tab.id)}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={tabPanelId (tab.id)}
className={cn (
'shrink-0 rounded-full border px-3 py-2 text-sm font-medium',
(activeTab === tab.id
? ['border-slate-900 bg-slate-900 text-white',
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
: ['border-border bg-background text-foreground']),
)}
onClick={() => {
void requestActiveTab (tab.id)
}}>
{tab.label}
{settingsTabErrors[tab.id] ? ' !' : ''}
</button>))}
</div>
{loading ? 'Loading...' : (
<div
id={tabPanelId (activeTab)}
role="tabpanel"
aria-labelledby={tabButtonId (activeTab)}>
{accountSectionProps && activeTab === 'account' && (
<AccountSection {...accountSectionProps}/>)}
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
{activeTab === 'keyboard' && (
<KeyboardSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('keyboard', { dirty, discard })
}}/>)}
{activeTab === 'behavior' && (
<BehaviourSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('behavior', { dirty, discard })
}}/>)}
</div>)}
</div>
<div className="hidden md:flex md:justify-center md:px-4">
<div className="grid w-full max-w-[58rem] grid-cols-[12rem_minmax(0,1fr)] gap-6">
<div
role="tablist"
aria-label="設定区分"
aria-orientation="vertical"
className="sticky top-4 flex h-fit flex-col gap-2">
{tabs.map (tab => (
<button
key={tab.id}
id={tabButtonId (tab.id)}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={tabPanelId (tab.id)}
tabIndex={activeTab === tab.id ? 0 : -1}
className={cn (
'rounded-xl border px-4 py-3 text-left text-sm font-medium',
(activeTab === tab.id
? ['border-slate-900 bg-slate-900 text-white',
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
: ['border-slate-300 bg-white text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']),
)}
onClick={() => {
void requestActiveTab (tab.id)
}}
onKeyDown={handleTabKeyDown}>
{tab.label}
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
</button>))}
</div>
<div className="w-full max-w-xl justify-self-center space-y-4">
<PageTitle></PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/>
{loading ? 'Loading...' : (
<div
id={tabPanelId (activeTab)}
role="tabpanel"
aria-labelledby={tabButtonId (activeTab)}>
{activeTab === 'account' && accountSectionProps && (
<AccountSection {...accountSectionProps}/>)}
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
{activeTab === 'keyboard' && (
<KeyboardSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('keyboard', { dirty, discard })
}}/>)}
{activeTab === 'behavior' && (
<BehaviourSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('behavior', { dirty, discard })
}}/>)}
</div>)}
</div>
</div>
</div>
<UserCodeDialogue
visible={userCodeVsbl}
onVisibleChange={setUserCodeVsbl}
user={user}
setUser={setUser}/>
<InheritDialogue
visible={inheritVsbl}
onVisibleChange={setInheritVsbl}
setUser={setUser}/>
</MainArea>)
}
export default SettingPage