1420 行
44 KiB
TypeScript
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
|