ファイル
btrc-hub/frontend/src/pages/users/SettingPage.tsx
T
2026-07-05 18:21:39 +09:00

1199 行
36 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { 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 {
applyThemeSelection,
DEFAULT_USER_SETTINGS,
deriveUserThemeModeFromSlotSelection,
fetchUserSettings,
fetchUserThemeSlots,
getClientActiveThemeSlot,
getDefaultUserThemeSlot,
hasStoredClientThemeSelection,
isUserThemeSlotSelection,
normaliseThemeSlotSelectionAgainstSlots,
normaliseUserThemeSlots,
setCachedUserThemeSlots,
setClientActiveThemeSlot,
resolveThemeFromSlotSelection,
updateUserSettings,
upsertUserThemeSlot,
USER_THEME_SLOT_SELECTIONS,
} from '@/lib/settings'
import { useKeyboardShortcutSettings } 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,
ClientTheme,
ThemeSlotSelection,
ThemeTokens,
ThemeTagColours,
UserSettings,
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' | 'theme' | 'keyboard' | 'behavior'
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 ThemeSelectionItem = {
id: ThemeSlotSelection
label: string
editable: boolean
baseTheme?: BuiltinThemeId
slotNo?: UserThemeSlotNo }
type ThemeTokenFieldSpec = {
key: ThemeTokenKey
label: string }
type ThemeTokenGroupSpec = {
title: string
fields: ThemeTokenFieldSpec[] }
type ThemeSectionProps = {
draftActiveThemeSlot: ThemeSlotSelection
draftThemeSlots: UserThemeSlotMap
editingThemeSlot: UserThemeSlotSelection | null
selectedTheme: ClientTheme
selectedThemeLabel: string
hasUnsavedChanges: boolean
settingsBaseErrors: string[]
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
onSelectThemeSlot: (selection: ThemeSlotSelection) => void
onEditThemeSlot: (selection: UserThemeSlotSelection) => void
onEnsureEditableTheme: () => void
onThemeTokenChange: (key: ThemeTokenKey, value: string) => void
onTagColourChange: (category: Category, colour: string) => void
onResetThemeSlot: (selection: UserThemeSlotSelection) => void
onThemeSubmit: () => void
onThemeDiscard: () => void }
const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' },
{ id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' },
{ id: 'behavior', label: '動作' } ]
const sectionClassName =
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
const themeSelections: ThemeSelectionItem[] = [
{ id: 'system', label: 'システム設定', editable: false },
{ id: 'builtin:light', label: '標準ライト', editable: false },
{ id: 'builtin:dark', label: '標準ダーク', editable: false },
{ id: 'user:light:1', label: 'ライト 1', editable: true, baseTheme: 'light', slotNo: 1 },
{ id: 'user:light:2', label: 'ライト 2', editable: true, baseTheme: 'light', slotNo: 2 },
{ id: 'user:light:3', label: 'ライト 3', editable: true, baseTheme: 'light', slotNo: 3 },
{ id: 'user:dark:1', label: 'ダーク 1', editable: true, baseTheme: 'dark', slotNo: 1 },
{ id: 'user:dark:2', label: 'ダーク 2', editable: true, baseTheme: 'dark', slotNo: 2 },
{ id: 'user:dark:3', label: 'ダーク 3', editable: true, baseTheme: 'dark', slotNo: 3 },
]
const themeTokenGroups: ThemeTokenGroupSpec[] = [
{ title: '基本色',
fields: [
{ key: 'background', label: '背景' },
{ key: 'foreground', label: '文字' },
{ key: 'primary', label: '主ボタン' },
{ key: 'primaryForeground', label: '主ボタン文字' },
{ key: 'accent', label: 'アクセント' },
{ key: 'accentForeground', label: 'アクセント文字' },
] },
{ title: 'パネル・カード',
fields: [
{ key: 'card', label: 'カード背景' },
{ key: 'cardForeground', label: 'カード文字' },
{ key: 'popover', label: 'ポップアップ背景' },
{ key: 'popoverForeground', label: 'ポップアップ文字' },
{ key: 'muted', label: '薄い背景' },
{ key: 'mutedForeground', label: '薄い文字' },
] },
{ title: '入力欄・境界線',
fields: [
{ key: 'border', label: '境界線' },
{ key: 'input', label: '入力欄' },
{ key: 'ring', label: 'フォーカスリング' },
] },
{ title: 'リンク',
fields: [
{ key: 'link', label: 'リンク' },
] },
{ title: '上部ナビ',
fields: [
{ key: 'topNavBackground', label: '背景' },
{ key: 'topNavForeground', label: '文字' },
{ key: 'topNavBorder', label: '境界線' },
{ key: 'topNavLink', label: 'リンク' },
{ key: 'topNavLinkHover', label: 'リンク hover' },
{ key: 'topNavActiveBackground', label: '選択中背景' },
{ key: 'topNavActiveForeground', label: '選択中文字' },
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
{ key: 'topNavSubmenuForeground', 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 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: ThemeSlotSelection): string =>
themeSelections.find (item => item.id === selection)?.label ?? 'システム設定'
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 = (
activeThemeSlot: ThemeSlotSelection,
themeSlots: UserThemeSlotMap,
): string =>
JSON.stringify ({
activeThemeSlot,
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
selection,
tokens: themeSlots[selection]?.tokens,
})),
})
const themeToSelection = (
theme: UserSettings['theme'],
): ThemeSlotSelection =>
theme === 'system'
? 'system'
: `builtin:${ theme }`
const themeRowBaseLabel = (
selection: ThemeSlotSelection,
): string =>
selection === 'system'
? '端末設定に従います'
: themeLabelBySelection (selection)
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 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> = (
{ draftActiveThemeSlot,
draftThemeSlots,
editingThemeSlot,
selectedTheme,
selectedThemeLabel,
hasUnsavedChanges,
settingsBaseErrors,
settingsFieldErrors,
onSelectThemeSlot,
onEditThemeSlot,
onEnsureEditableTheme,
onThemeTokenChange,
onTagColourChange,
onResetThemeSlot,
onThemeSubmit,
onThemeDiscard },
) => (
<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">
: {selectedThemeLabel}
</p>
{editingThemeSlot && (
<p className="text-sm text-muted-foreground">
: {themeLabelBySelection (editingThemeSlot)}
</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">
{themeSelections.map (selection => {
const themeSlot =
selection.editable
? draftThemeSlots[selection.id as UserThemeSlotSelection]
: null
const isSelected = draftActiveThemeSlot === selection.id
const isEditing = editingThemeSlot === selection.id
return (
<div
key={selection.id}
className={cn (
'space-y-3 rounded-xl border border-border/70 bg-background/70 p-4',
isSelected && 'ring-1 ring-primary/30',
)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold">{selection.label}</p>
<p className="text-sm text-muted-foreground">
{themeRowBaseLabel (selection.id)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={isSelected ? 'default' : 'outline'}
onClick={() => onSelectThemeSlot (selection.id)}>
</Button>
{selection.editable && (
<>
<Button
type="button"
variant={isEditing ? 'default' : 'outline'}
onClick={() =>
onEditThemeSlot (selection.id as UserThemeSlotSelection)}>
</Button>
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges) || !(isEditing || isSelected)}
onClick={onThemeSubmit}>
</Button>
<Button
type="button"
variant="ghost"
onClick={() =>
onResetThemeSlot (selection.id as UserThemeSlotSelection)}>
</Button>
</>)}
</div>
</div>
{themeSlot && (
<div style={themePreviewStyle (themeSlot.tokens.tagColours)}>
<TagLink
tag={previewTags.general}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>)}
</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={hslTokenToHex (selectedTheme.tokens[field.key])}
onPointerDown={onEnsureEditableTheme}
onFocus={onEnsureEditableTheme}
onChange={ev =>
onThemeTokenChange (field.key, hexToHslToken (ev.target.value))}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
</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]}
onPointerDown={onEnsureEditableTheme}
onFocus={onEnsureEditableTheme}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
</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 [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
const [savedActiveThemeSlot, setSavedActiveThemeSlot] =
useState<ThemeSlotSelection> (getClientActiveThemeSlot ())
const [draftActiveThemeSlot, setDraftActiveThemeSlot] =
useState<ThemeSlotSelection> (getClientActiveThemeSlot ())
const [savedThemeSlots, setSavedThemeSlots] =
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
const [draftThemeSlots, setDraftThemeSlots] =
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
const [editingThemeSlot, setEditingThemeSlot] =
useState<UserThemeSlotSelection | null> (null)
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 selectedTheme = useMemo<ClientTheme> (
() => {
const resolvedTheme =
resolveThemeFromSlotSelection (draftActiveThemeSlot, draftThemeSlots)
return {
id: resolvedTheme.baseTheme,
name: themeLabelBySelection (draftActiveThemeSlot),
builtin: resolvedTheme.builtin,
baseTheme: resolvedTheme.baseTheme,
tokens: resolvedTheme.tokens,
}
},
[draftActiveThemeSlot, draftThemeSlots],
)
const hasUnsavedThemeChanges = useMemo (
() =>
serialiseThemeDraft (draftActiveThemeSlot, draftThemeSlots)
!== serialiseThemeDraft (savedActiveThemeSlot, savedThemeSlots),
[draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots],
)
const setActiveTab = (tab: SettingsTab) => {
const params = new URLSearchParams (location.search)
params.set ('tab', tab)
navigate (
`${ location.pathname }?${ params.toString () }`,
{ replace: true })
}
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
() => (
{ account: Boolean (userFieldErrors.name?.length),
theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: conflictActionIds.size > 0,
behavior: false }),
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name],
)
const applyDraftThemeSelection = (
activeThemeSlot: ThemeSlotSelection,
themeSlots: UserThemeSlotMap,
) => {
setDraftActiveThemeSlot (activeThemeSlot)
setDraftThemeSlots (themeSlots)
setDraftSettings (current => ({
...current,
theme: deriveUserThemeModeFromSlotSelection (activeThemeSlot),
}))
applyThemeSelection (activeThemeSlot, themeSlots)
}
const confirmThemeSlotSwitch = async (
onConfirmed: (themeSlots: UserThemeSlotMap) => void,
): Promise<void> => {
if (!(hasUnsavedThemeChanges))
{
onConfirmed (draftThemeSlots)
return
}
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま切り替えると、保存していない変更は失われます。',
confirmText: '変更を破棄して切り替える',
cancelText: 'このまま編輯する',
})
if (!(confirmed))
return
setDraftActiveThemeSlot (savedActiveThemeSlot)
setDraftThemeSlots (savedThemeSlots)
setEditingThemeSlot (null)
onConfirmed (savedThemeSlots)
}
const ensureEditableThemeSlot = async (): Promise<UserThemeSlotSelection | null> => {
if (editingThemeSlot != null)
return editingThemeSlot
if (isUserThemeSlotSelection (draftActiveThemeSlot))
{
setEditingThemeSlot (draftActiveThemeSlot)
return draftActiveThemeSlot
}
const choice = await dialogue.choice<UserThemeSlotSelection> ({
title: '保存先を選択してください',
description: '色を変更する前に、保存先のテーマ枠を選択してください。',
cancelText: '取消',
choices: themeSelections
.filter (selection => selection.editable)
.map (selection => ({
value: selection.id as UserThemeSlotSelection,
label: selection.label,
})),
})
if (choice == null)
return null
setEditingThemeSlot (choice)
applyDraftThemeSelection (choice, draftThemeSlots)
return choice
}
const updateDraftThemeSlot = (
selection: UserThemeSlotSelection,
updater: (tokens: ThemeTokens) => ThemeTokens,
options?: { activeThemeSlot?: ThemeSlotSelection },
) => {
const currentThemeSlot =
draftThemeSlots[selection]
?? ensureCompleteThemeSlots ({ })[selection]
const nextThemeSlots = {
...draftThemeSlots,
[selection]: {
...currentThemeSlot,
tokens: updater (currentThemeSlot.tokens),
},
}
applyDraftThemeSelection (
options?.activeThemeSlot ?? draftActiveThemeSlot,
nextThemeSlots,
)
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const currentIndex = tabs.findIndex (tab => tab.id === activeTab)
switch (event.key)
{
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault ()
setActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
break
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault ()
setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
break
case 'Home':
event.preventDefault ()
setActiveTab (tabs[0].id)
break
case 'End':
event.preventDefault ()
setActiveTab (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 }))
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 === draftActiveThemeSlot
&& isUserThemeSlotSelection (draftActiveThemeSlot)
&& 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 themeMode =
deriveUserThemeModeFromSlotSelection (draftActiveThemeSlot)
const data = await updateUserSettings ({ theme: themeMode })
const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)
setDraftSettings ({ ...data, theme: themeMode })
setSavedActiveThemeSlot (draftActiveThemeSlot)
setSavedThemeSlots (completeThemeSlots)
setDraftThemeSlots (completeThemeSlots)
setEditingThemeSlot (
isUserThemeSlotSelection (draftActiveThemeSlot)
? draftActiveThemeSlot
: null,
)
setClientActiveThemeSlot (draftActiveThemeSlot)
setCachedUserThemeSlots (completeThemeSlots)
applyThemeSelection (draftActiveThemeSlot, completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' })
}
catch (submitError)
{
applySettingsValidationError (submitError)
toast ({ title: 'テーマ設定を保存できませんでした.' })
}
}
const handleThemeDiscard = () => {
setDraftActiveThemeSlot (savedActiveThemeSlot)
setDraftThemeSlots (savedThemeSlots)
setEditingThemeSlot (
isUserThemeSlotSelection (savedActiveThemeSlot)
? savedActiveThemeSlot
: null,
)
applyThemeSelection (savedActiveThemeSlot, savedThemeSlots)
}
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 initialSelection = (
hasStoredClientThemeSelection ()
? getClientActiveThemeSlot ()
: themeToSelection (settings.theme)
)
const nextSelection = normaliseThemeSlotSelectionAgainstSlots (
initialSelection,
storedThemeSlots,
)
const themeMode = deriveUserThemeModeFromSlotSelection (nextSelection)
setDraftSettings ({ ...settings, theme: themeMode })
setSavedActiveThemeSlot (nextSelection)
setDraftActiveThemeSlot (nextSelection)
setSavedThemeSlots (completeThemeSlots)
setDraftThemeSlots (completeThemeSlots)
setEditingThemeSlot (
isUserThemeSlotSelection (nextSelection)
? nextSelection
: null,
)
setCachedUserThemeSlots (completeThemeSlots)
applyThemeSelection (nextSelection, completeThemeSlots)
if (nextSelection !== initialSelection)
setClientActiveThemeSlot (nextSelection)
setSettingsError (null)
}
catch
{
if (cancelled)
return
setSettingsError ('設定の読込みに失敗しました.')
}
finally
{
if (!(cancelled))
setLoading (false)
}
}) ()
return () => {
cancelled = true
}
}, [user])
useEffect (() => {
if (userFieldErrors.name?.length)
setActiveTab ('account')
}, [location.pathname, location.search, userFieldErrors.name])
useEffect (() => {
if (settingsFieldErrors.theme?.length)
setActiveTab ('theme')
}, [location.pathname, location.search, settingsFieldErrors.theme])
useEffect (() => {
applyThemeSelection (draftActiveThemeSlot, draftThemeSlots)
return () => {
applyThemeSelection (savedActiveThemeSlot, savedThemeSlots)
}
}, [draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots])
const accountSectionProps: AccountSectionProps | null =
user
? {
user,
name,
onNameChange: setName,
onInheritOpen: () => setInheritVsbl (true),
onUserCodeOpen: () => setUserCodeVsbl (true),
onUserSubmit: handleUserSubmit,
userBaseErrors,
userFieldErrors,
}
: null
const themeSectionProps: ThemeSectionProps = {
draftActiveThemeSlot,
draftThemeSlots,
editingThemeSlot,
selectedTheme,
selectedThemeLabel: themeLabelBySelection (draftActiveThemeSlot),
hasUnsavedChanges: hasUnsavedThemeChanges,
settingsBaseErrors,
settingsFieldErrors,
onSelectThemeSlot: selection => {
void confirmThemeSlotSwitch (themeSlots => {
setEditingThemeSlot (null)
applyDraftThemeSelection (selection, themeSlots)
})
},
onEditThemeSlot: selection => {
void confirmThemeSlotSwitch (themeSlots => {
setEditingThemeSlot (selection)
applyDraftThemeSelection (selection, themeSlots)
})
},
onEnsureEditableTheme: () => {
void ensureEditableThemeSlot ()
},
onThemeTokenChange: (key, value) => {
void (async () => {
const editableThemeSlot = await ensureEditableThemeSlot ()
if (editableThemeSlot == null)
return
updateDraftThemeSlot (editableThemeSlot, tokens => ({
...tokens,
[key]: value,
}), { activeThemeSlot: editableThemeSlot })
}) ()
},
onTagColourChange: (category, colour) => {
void (async () => {
const editableThemeSlot = await ensureEditableThemeSlot ()
if (editableThemeSlot == null)
return
updateDraftThemeSlot (editableThemeSlot, tokens => ({
...tokens,
tagColours: {
...tokens.tagColours,
[category]: colour,
},
}), { activeThemeSlot: editableThemeSlot })
}) ()
},
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={() => setActiveTab (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}/>)}
{activeTab === 'behavior' && (
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</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={() => setActiveTab (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}/>)}
{activeTab === 'behavior' && (
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</div>)}
</div>
</div>
</div>
<UserCodeDialogue
visible={userCodeVsbl}
onVisibleChange={setUserCodeVsbl}
user={user}
setUser={setUser}/>
<InheritDialogue
visible={inheritVsbl}
onVisibleChange={setInheritVsbl}
setUser={setUser}/>
</MainArea>)
}
export default SettingPage