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> } type UserFormField = 'name' type SettingsFormField = 'theme' type SettingsTab = 'account' | 'behavior' | 'theme' | 'keyboard' type ThemeTokenKey = Exclude 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> } 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> 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 } 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 = { 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 ( (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 = ( { name, onInheritOpen, onNameChange, onUserCodeOpen, onUserSubmit, user, userBaseErrors, userFieldErrors }, ) => (

アカウント

{({ describedBy, invalid }) => ( <> onNameChange (ev.target.value)}/> {!(user.name) && (

名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!

)} )}
) const ThemeSection: FC = ( { draftThemeMode, draftActiveLightThemeSlotNo, draftActiveDarkThemeSlotNo, editingThemeSlot, selectedTheme, displayedThemeLabel, systemThemeLabel, hasUnsavedChanges, settingsBaseErrors, settingsFieldErrors, onSelectThemeMode, onSelectLightThemeSlotNo, onSelectDarkThemeSlotNo, onSelectEditingThemeSlot, onThemeTokenChange, onTagColourChange, onResetThemeSlot, onThemeSubmit, onThemeDiscard }, ) => { const currentEditableSelection = editingThemeSlot return (

テーマ

現在表示中: {displayedThemeLabel}

{draftThemeMode === 'system' && (

システム設定: {systemThemeLabel}

)}

編輯対象: {themeLabelBySelection (currentEditableSelection)}

{hasUnsavedChanges && (

未保存の変更があります

)}

変更は保存時に反映されます。

{themeTokenGroups.map (group => (

{group.title}

{group.fields.map (field => (

{field.label}

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"/>
))}
))}

タグ

{CATEGORIES.map (category => (

{CATEGORY_NAMES[category]}

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"/>
))}
) } const SettingPage: FC = ({ 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 (null) const [savedThemeMode, setSavedThemeMode] = useState (getClientThemeMode ()) const [draftThemeMode, setDraftThemeMode] = useState (getClientThemeMode ()) const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] = useState (getClientActiveLightThemeSlotNo ()) const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] = useState (getClientActiveLightThemeSlotNo ()) const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] = useState (getClientActiveDarkThemeSlotNo ()) const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] = useState (getClientActiveDarkThemeSlotNo ()) const [savedThemeSlots, setSavedThemeSlots] = useState (() => ensureCompleteThemeSlots ({ })) const [draftThemeSlots, setDraftThemeSlots] = useState (() => ensureCompleteThemeSlots ({ })) const [savedEditingThemeSlot, setSavedEditingThemeSlot] = useState ('user:light:1') const [editingThemeSlot, setEditingThemeSlot] = useState ('user:light:1') const [dirtyStates, setDirtyStates] = useState>> ({ }) const [userCodeVsbl, setUserCodeVsbl] = useState (false) const { conflictActionIds } = useKeyboardShortcutSettings () const { baseErrors: userBaseErrors, fieldErrors: userFieldErrors, clearValidationErrors: clearUserValidationErrors, applyValidationError: applyUserValidationError } = useValidationErrors () const { baseErrors: settingsBaseErrors, fieldErrors: settingsFieldErrors, clearValidationErrors: clearSettingsValidationErrors, applyValidationError: applySettingsValidationError } = useValidationErrors () const activeTab = useMemo (() => parseTab (location.search), [location.search]) const displayedThemeSelection = useMemo ( () => resolveDisplayedTheme ({ themeMode: draftThemeMode, activeLightThemeSlotNo: draftActiveLightThemeSlotNo, activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots), [draftActiveDarkThemeSlotNo, draftActiveLightThemeSlotNo, draftThemeMode, draftThemeSlots]) const selectedTheme = useMemo ( () => { 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 => dialogue.confirm ({ title: '未保存の変更があります', description, confirmText, cancelText, variant: 'danger' }) const settingsTabErrors = useMemo> ( () => ( { 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 => { const currentDirtyState = dirtyStates[activeTab] if (currentDirtyState?.dirty) await currentDirtyState.discard () setDirtyStates ({ }) } const requestActiveTab = async ( nextTab: SettingsTab, ): Promise => { 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 => { 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) => { 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 ( `/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 ( 設定 | {SITE_TITLE}
設定
{tabs.map (tab => ( ))}
{loading ? 'Loading...' : (
{accountSectionProps && activeTab === 'account' && ( )} {activeTab === 'theme' && } {activeTab === 'keyboard' && ( { updateTabDirtyState ('keyboard', { dirty, discard }) }}/>)} {activeTab === 'behavior' && ( { updateTabDirtyState ('behavior', { dirty, discard }) }}/>)}
)}
{tabs.map (tab => ( ))}
設定 {loading ? 'Loading...' : (
{activeTab === 'account' && accountSectionProps && ( )} {activeTab === 'theme' && } {activeTab === 'keyboard' && ( { updateTabDirtyState ('keyboard', { dirty, discard }) }}/>)} {activeTab === 'behavior' && ( { updateTabDirtyState ('behavior', { dirty, discard }) }}/>)}
)}
) } export default SettingPage