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

アカウント

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

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

)} )}
) const ThemeSection: FC = ( { draftActiveThemeSlot, draftThemeSlots, editingThemeSlot, selectedTheme, selectedThemeLabel, hasUnsavedChanges, settingsBaseErrors, settingsFieldErrors, onSelectThemeSlot, onEditThemeSlot, onEnsureEditableTheme, onThemeTokenChange, onTagColourChange, onResetThemeSlot, onThemeSubmit, onThemeDiscard }, ) => (

テーマ

現在の表示: {selectedThemeLabel}

{editingThemeSlot && (

編輯対象: {themeLabelBySelection (editingThemeSlot)}

)} {hasUnsavedChanges && (

未保存の変更があります

)}
{themeSelections.map (selection => { const themeSlot = selection.editable ? draftThemeSlots[selection.id as UserThemeSlotSelection] : null const isSelected = draftActiveThemeSlot === selection.id const isEditing = editingThemeSlot === selection.id return (

{selection.label}

{themeRowBaseLabel (selection.id)}

{selection.editable && ( <> )}
{themeSlot && (
)}
) })}
{themeTokenGroups.map (group => (

{group.title}

{group.fields.map (field => (

{field.label}

onThemeTokenChange (field.key, hexToHslToken (ev.target.value))} className="h-10 w-16 cursor-pointer rounded border border-border bg-transparent p-1"/>
))}
))}

タグ

{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"/>
))}
) 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 [, setDraftSettings] = useState (DEFAULT_USER_SETTINGS) const [savedActiveThemeSlot, setSavedActiveThemeSlot] = useState (getClientActiveThemeSlot ()) const [draftActiveThemeSlot, setDraftActiveThemeSlot] = useState (getClientActiveThemeSlot ()) const [savedThemeSlots, setSavedThemeSlots] = useState (() => ensureCompleteThemeSlots ({ })) const [draftThemeSlots, setDraftThemeSlots] = useState (() => ensureCompleteThemeSlots ({ })) const [editingThemeSlot, setEditingThemeSlot] = useState (null) 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 selectedTheme = useMemo ( () => { 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> ( () => ( { 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 => { 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 => { if (editingThemeSlot != null) return editingThemeSlot if (isUserThemeSlotSelection (draftActiveThemeSlot)) { setEditingThemeSlot (draftActiveThemeSlot) return draftActiveThemeSlot } const choice = await dialogue.choice ({ 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) => { 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 ( `/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 ( 設定 | {SITE_TITLE}
設定
{tabs.map (tab => ( ))}
{loading ? 'Loading...' : (
{accountSectionProps && activeTab === 'account' && ( )} {activeTab === 'theme' && } {activeTab === 'keyboard' && ( )} {activeTab === 'behavior' && ( )}
)}
{tabs.map (tab => ( ))}
設定 {loading ? 'Loading...' : (
{activeTab === 'account' && accountSectionProps && ( )} {activeTab === 'theme' && } {activeTab === 'keyboard' && ( )} {activeTab === 'behavior' && ( )}
)}
) } export default SettingPage