1199 行
36 KiB
TypeScript
1199 行
36 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { Helmet } from 'react-helmet-async'
|
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
|
|
import FieldError from '@/components/common/FieldError'
|
|
import FormField from '@/components/common/FormField'
|
|
import Label from '@/components/common/Label'
|
|
import PageTitle from '@/components/common/PageTitle'
|
|
import MainArea from '@/components/layout/MainArea'
|
|
import TagLink from '@/components/TagLink'
|
|
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
|
import { Button } from '@/components/ui/button'
|
|
import { toast } from '@/components/ui/use-toast'
|
|
import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
|
|
import InheritDialogue from '@/components/users/InheritDialogue'
|
|
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
|
|
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
|
|
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
|
|
import { SITE_TITLE } from '@/config'
|
|
import { apiPut } from '@/lib/api'
|
|
import {
|
|
applyThemeSelection,
|
|
DEFAULT_USER_SETTINGS,
|
|
deriveUserThemeModeFromSlotSelection,
|
|
fetchUserSettings,
|
|
fetchUserThemeSlots,
|
|
getClientActiveThemeSlot,
|
|
getDefaultUserThemeSlot,
|
|
hasStoredClientThemeSelection,
|
|
isUserThemeSlotSelection,
|
|
normaliseThemeSlotSelectionAgainstSlots,
|
|
normaliseUserThemeSlots,
|
|
setCachedUserThemeSlots,
|
|
setClientActiveThemeSlot,
|
|
resolveThemeFromSlotSelection,
|
|
updateUserSettings,
|
|
upsertUserThemeSlot,
|
|
USER_THEME_SLOT_SELECTIONS,
|
|
} from '@/lib/settings'
|
|
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
|
|
import { cn, inputClass } from '@/lib/utils'
|
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
|
|
|
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
|
|
|
|
import type { Category, Tag, User } from '@/types'
|
|
import type {
|
|
BuiltinThemeId,
|
|
ClientTheme,
|
|
ThemeSlotSelection,
|
|
ThemeTokens,
|
|
ThemeTagColours,
|
|
UserSettings,
|
|
UserThemeSlotMap,
|
|
UserThemeSlotNo,
|
|
UserThemeSlotSelection,
|
|
} from '@/lib/settings'
|
|
|
|
type Props = {
|
|
user: User | null
|
|
setUser: Dispatch<SetStateAction<User | null>> }
|
|
|
|
type UserFormField = 'name'
|
|
type SettingsFormField = 'theme'
|
|
type SettingsTab = 'account' | 'theme' | 'keyboard' | 'behavior'
|
|
type ThemeTokenKey = Exclude<keyof ThemeTokens, 'tagColours'>
|
|
|
|
type TabSpec = {
|
|
id: SettingsTab
|
|
label: string }
|
|
|
|
type AccountSectionProps = {
|
|
user: User
|
|
name: string
|
|
onNameChange: (value: string) => void
|
|
onInheritOpen: () => void
|
|
onUserCodeOpen: () => void
|
|
onUserSubmit: () => void
|
|
userBaseErrors: string[]
|
|
userFieldErrors: Partial<Record<UserFormField, string[]>> }
|
|
|
|
type ThemeSelectionItem = {
|
|
id: ThemeSlotSelection
|
|
label: string
|
|
editable: boolean
|
|
baseTheme?: BuiltinThemeId
|
|
slotNo?: UserThemeSlotNo }
|
|
|
|
type ThemeTokenFieldSpec = {
|
|
key: ThemeTokenKey
|
|
label: string }
|
|
|
|
type ThemeTokenGroupSpec = {
|
|
title: string
|
|
fields: ThemeTokenFieldSpec[] }
|
|
|
|
type ThemeSectionProps = {
|
|
draftActiveThemeSlot: ThemeSlotSelection
|
|
draftThemeSlots: UserThemeSlotMap
|
|
editingThemeSlot: UserThemeSlotSelection | null
|
|
selectedTheme: ClientTheme
|
|
selectedThemeLabel: string
|
|
hasUnsavedChanges: boolean
|
|
settingsBaseErrors: string[]
|
|
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
|
|
onSelectThemeSlot: (selection: ThemeSlotSelection) => void
|
|
onEditThemeSlot: (selection: UserThemeSlotSelection) => void
|
|
onEnsureEditableTheme: () => void
|
|
onThemeTokenChange: (key: ThemeTokenKey, value: string) => void
|
|
onTagColourChange: (category: Category, colour: string) => void
|
|
onResetThemeSlot: (selection: UserThemeSlotSelection) => void
|
|
onThemeSubmit: () => void
|
|
onThemeDiscard: () => void }
|
|
|
|
const tabs: TabSpec[] = [
|
|
{ id: 'account', label: 'アカウント' },
|
|
{ id: 'theme', label: 'テーマ' },
|
|
{ id: 'keyboard', label: 'キーボード' },
|
|
{ id: 'behavior', label: '動作' } ]
|
|
|
|
const sectionClassName =
|
|
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
|
|
|
const themeSelections: ThemeSelectionItem[] = [
|
|
{ id: 'system', label: 'システム設定', editable: false },
|
|
{ id: 'builtin:light', label: '標準ライト', editable: false },
|
|
{ id: 'builtin:dark', label: '標準ダーク', editable: false },
|
|
{ id: 'user:light:1', label: 'ライト 1', editable: true, baseTheme: 'light', slotNo: 1 },
|
|
{ id: 'user:light:2', label: 'ライト 2', editable: true, baseTheme: 'light', slotNo: 2 },
|
|
{ id: 'user:light:3', label: 'ライト 3', editable: true, baseTheme: 'light', slotNo: 3 },
|
|
{ id: 'user:dark:1', label: 'ダーク 1', editable: true, baseTheme: 'dark', slotNo: 1 },
|
|
{ id: 'user:dark:2', label: 'ダーク 2', editable: true, baseTheme: 'dark', slotNo: 2 },
|
|
{ id: 'user:dark:3', label: 'ダーク 3', editable: true, baseTheme: 'dark', slotNo: 3 },
|
|
]
|
|
|
|
const themeTokenGroups: ThemeTokenGroupSpec[] = [
|
|
{ title: '基本色',
|
|
fields: [
|
|
{ key: 'background', label: '背景' },
|
|
{ key: 'foreground', label: '文字' },
|
|
{ key: 'primary', label: '主ボタン' },
|
|
{ key: 'primaryForeground', label: '主ボタン文字' },
|
|
{ key: 'accent', label: 'アクセント' },
|
|
{ key: 'accentForeground', label: 'アクセント文字' },
|
|
] },
|
|
{ title: 'パネル・カード',
|
|
fields: [
|
|
{ key: 'card', label: 'カード背景' },
|
|
{ key: 'cardForeground', label: 'カード文字' },
|
|
{ key: 'popover', label: 'ポップアップ背景' },
|
|
{ key: 'popoverForeground', label: 'ポップアップ文字' },
|
|
{ key: 'muted', label: '薄い背景' },
|
|
{ key: 'mutedForeground', label: '薄い文字' },
|
|
] },
|
|
{ title: '入力欄・境界線',
|
|
fields: [
|
|
{ key: 'border', label: '境界線' },
|
|
{ key: 'input', label: '入力欄' },
|
|
{ key: 'ring', label: 'フォーカスリング' },
|
|
] },
|
|
{ title: 'リンク',
|
|
fields: [
|
|
{ key: 'link', label: 'リンク' },
|
|
] },
|
|
{ title: '上部ナビ',
|
|
fields: [
|
|
{ key: 'topNavBackground', label: '背景' },
|
|
{ key: 'topNavForeground', label: '文字' },
|
|
{ key: 'topNavBorder', label: '境界線' },
|
|
{ key: 'topNavLink', label: 'リンク' },
|
|
{ key: 'topNavLinkHover', label: 'リンク hover' },
|
|
{ key: 'topNavActiveBackground', label: '選択中背景' },
|
|
{ key: 'topNavActiveForeground', label: '選択中文字' },
|
|
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
|
|
{ key: 'topNavSubmenuForeground', label: 'サブメニュー文字' },
|
|
] },
|
|
]
|
|
|
|
const previewTag = (
|
|
id: number,
|
|
name: string,
|
|
category: Category,
|
|
postCount: number,
|
|
hasWiki: boolean,
|
|
options?: { hasDeerjikists?: boolean },
|
|
): Tag => ({
|
|
id,
|
|
name,
|
|
category,
|
|
deprecatedAt: null,
|
|
aliases: [],
|
|
parents: [],
|
|
postCount,
|
|
createdAt: '',
|
|
updatedAt: '',
|
|
hasWiki,
|
|
materialId: null,
|
|
hasDeerjikists: options?.hasDeerjikists ?? false,
|
|
children: [] })
|
|
|
|
const previewTags: Record<Category, Tag> = {
|
|
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
|
|
meme: previewTag (2, 'ミーム', 'meme', 34, true),
|
|
character: previewTag (3, 'キャラクター', 'character', 56, false),
|
|
general: previewTag (4, '一般', 'general', 78, true),
|
|
material: previewTag (5, '素材', 'material', 90, false),
|
|
meta: previewTag (6, 'メタ', 'meta', 21, true),
|
|
nico: previewTag (7, 'nico:sm9', 'nico', 43, false) }
|
|
|
|
const parseTab = (search: string): SettingsTab => {
|
|
const tab = new URLSearchParams (search).get ('tab')
|
|
return (tabs.some (candidate => candidate.id === tab)
|
|
? (tab as SettingsTab)
|
|
: 'account')
|
|
}
|
|
|
|
const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }`
|
|
const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }`
|
|
|
|
const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
|
|
Object.fromEntries (
|
|
CATEGORIES.flatMap (category => [
|
|
[`--tag-colour-${ category }`, tagColours[category]],
|
|
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
|
|
|
|
const themeLabelBySelection = (selection: ThemeSlotSelection): string =>
|
|
themeSelections.find (item => item.id === selection)?.label ?? 'システム設定'
|
|
|
|
const ensureCompleteThemeSlots = (
|
|
themeSlots: UserThemeSlotMap,
|
|
): UserThemeSlotMap =>
|
|
USER_THEME_SLOT_SELECTIONS.reduce<UserThemeSlotMap> (
|
|
(complete, selection) => {
|
|
const [_, baseTheme, slotNo] = selection.split (':')
|
|
complete[selection] =
|
|
themeSlots[selection]
|
|
?? getDefaultUserThemeSlot (
|
|
baseTheme as BuiltinThemeId,
|
|
Number (slotNo) as UserThemeSlotNo,
|
|
)
|
|
return complete
|
|
},
|
|
{ ...themeSlots },
|
|
)
|
|
|
|
const serialiseThemeDraft = (
|
|
activeThemeSlot: ThemeSlotSelection,
|
|
themeSlots: UserThemeSlotMap,
|
|
): string =>
|
|
JSON.stringify ({
|
|
activeThemeSlot,
|
|
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
|
|
selection,
|
|
tokens: themeSlots[selection]?.tokens,
|
|
})),
|
|
})
|
|
|
|
const themeToSelection = (
|
|
theme: UserSettings['theme'],
|
|
): ThemeSlotSelection =>
|
|
theme === 'system'
|
|
? 'system'
|
|
: `builtin:${ theme }`
|
|
|
|
const themeRowBaseLabel = (
|
|
selection: ThemeSlotSelection,
|
|
): string =>
|
|
selection === 'system'
|
|
? '端末設定に従います'
|
|
: themeLabelBySelection (selection)
|
|
|
|
|
|
const formatHslComponent = (value: number): string =>
|
|
Number.isInteger (value) ? String (value) : value.toFixed (1).replace (/\.0$/, '')
|
|
|
|
|
|
const hslTokenToHex = (token: string): string => {
|
|
const match =
|
|
token.match (
|
|
/^(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/,
|
|
)
|
|
if (match == null)
|
|
return '#000000'
|
|
|
|
const hue = Number (match[1]) / 360
|
|
const saturation = Number (match[2]) / 100
|
|
const lightness = Number (match[3]) / 100
|
|
|
|
if (saturation === 0)
|
|
{
|
|
const channel = Math.round (lightness * 255)
|
|
const hex = channel.toString (16).padStart (2, '0')
|
|
return `#${ hex }${ hex }${ hex }`
|
|
}
|
|
|
|
const hueToRgb = (
|
|
p: number,
|
|
q: number,
|
|
t: number,
|
|
): number => {
|
|
let adjusted = t
|
|
|
|
if (adjusted < 0)
|
|
adjusted += 1
|
|
if (adjusted > 1)
|
|
adjusted -= 1
|
|
if (adjusted < 1 / 6)
|
|
return p + (q - p) * 6 * adjusted
|
|
if (adjusted < 1 / 2)
|
|
return q
|
|
if (adjusted < 2 / 3)
|
|
return p + (q - p) * (2 / 3 - adjusted) * 6
|
|
return p
|
|
}
|
|
|
|
const q =
|
|
lightness < .5
|
|
? lightness * (1 + saturation)
|
|
: lightness + saturation - lightness * saturation
|
|
const p = 2 * lightness - q
|
|
const red = Math.round (hueToRgb (p, q, hue + 1 / 3) * 255)
|
|
const green = Math.round (hueToRgb (p, q, hue) * 255)
|
|
const blue = Math.round (hueToRgb (p, q, hue - 1 / 3) * 255)
|
|
|
|
return `#${ [red, green, blue]
|
|
.map (channel => channel.toString (16).padStart (2, '0'))
|
|
.join ('') }`
|
|
}
|
|
|
|
|
|
const hexToHslToken = (hex: string): string => {
|
|
const red = parseInt (hex.slice (1, 3), 16) / 255
|
|
const green = parseInt (hex.slice (3, 5), 16) / 255
|
|
const blue = parseInt (hex.slice (5, 7), 16) / 255
|
|
const max = Math.max (red, green, blue)
|
|
const min = Math.min (red, green, blue)
|
|
const lightness = (max + min) / 2
|
|
|
|
if (max === min)
|
|
return `0 0% ${ formatHslComponent (lightness * 100) }%`
|
|
|
|
const delta = max - min
|
|
const saturation =
|
|
lightness > .5
|
|
? delta / (2 - max - min)
|
|
: delta / (max + min)
|
|
|
|
let hue = 0
|
|
switch (max)
|
|
{
|
|
case red:
|
|
hue = (green - blue) / delta + (green < blue ? 6 : 0)
|
|
break
|
|
case green:
|
|
hue = (blue - red) / delta + 2
|
|
break
|
|
case blue:
|
|
hue = (red - green) / delta + 4
|
|
break
|
|
}
|
|
|
|
return [
|
|
formatHslComponent (hue * 60),
|
|
`${ formatHslComponent (saturation * 100) }%`,
|
|
`${ formatHslComponent (lightness * 100) }%`,
|
|
].join (' ')
|
|
}
|
|
|
|
|
|
const AccountSection: FC<AccountSectionProps> = (
|
|
{ name,
|
|
onInheritOpen,
|
|
onNameChange,
|
|
onUserCodeOpen,
|
|
onUserSubmit,
|
|
user,
|
|
userBaseErrors,
|
|
userFieldErrors },
|
|
) => (
|
|
<section className={sectionClassName}>
|
|
<h2 className="text-xl font-bold">アカウント</h2>
|
|
<FieldError messages={userBaseErrors}/>
|
|
|
|
<FormField label="表示名" messages={userFieldErrors.name}>
|
|
{({ describedBy, invalid }) => (
|
|
<>
|
|
<input
|
|
type="text"
|
|
aria-describedby={describedBy}
|
|
aria-invalid={invalid}
|
|
className={inputClass (invalid)}
|
|
value={name}
|
|
placeholder="名もなきニジラー"
|
|
onChange={ev => onNameChange (ev.target.value)}/>
|
|
{!(user.name) && (
|
|
<p className="mt-1 text-sm text-red-500">
|
|
名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!
|
|
</p>)}
|
|
</>)}
|
|
</FormField>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button type="button" onClick={onUserSubmit}>
|
|
表示名を更新
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>引継ぎ</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
onClick={onUserCodeOpen}
|
|
className="bg-gray-600 text-white"
|
|
disabled={!(user)}>
|
|
引継ぎコードを表示
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={onInheritOpen}
|
|
className="bg-red-600 text-white"
|
|
disabled={!(user)}>
|
|
ほかのブラウザから引継ぐ
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>)
|
|
|
|
const ThemeSection: FC<ThemeSectionProps> = (
|
|
{ draftActiveThemeSlot,
|
|
draftThemeSlots,
|
|
editingThemeSlot,
|
|
selectedTheme,
|
|
selectedThemeLabel,
|
|
hasUnsavedChanges,
|
|
settingsBaseErrors,
|
|
settingsFieldErrors,
|
|
onSelectThemeSlot,
|
|
onEditThemeSlot,
|
|
onEnsureEditableTheme,
|
|
onThemeTokenChange,
|
|
onTagColourChange,
|
|
onResetThemeSlot,
|
|
onThemeSubmit,
|
|
onThemeDiscard },
|
|
) => (
|
|
<section className={sectionClassName}>
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<h2 className="text-xl font-bold">テーマ</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
現在の表示: {selectedThemeLabel}
|
|
</p>
|
|
{editingThemeSlot && (
|
|
<p className="text-sm text-muted-foreground">
|
|
編輯対象: {themeLabelBySelection (editingThemeSlot)}
|
|
</p>)}
|
|
{hasUnsavedChanges && (
|
|
<p className="text-sm text-amber-700 dark:text-amber-300">
|
|
未保存の変更があります
|
|
</p>)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={!(hasUnsavedChanges)}
|
|
onClick={onThemeDiscard}>
|
|
変更を破棄
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={!(hasUnsavedChanges)}
|
|
onClick={onThemeSubmit}>
|
|
保存
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<FieldError messages={settingsBaseErrors}/>
|
|
<FieldError messages={settingsFieldErrors.theme ?? []}/>
|
|
|
|
<div className="space-y-3">
|
|
{themeSelections.map (selection => {
|
|
const themeSlot =
|
|
selection.editable
|
|
? draftThemeSlots[selection.id as UserThemeSlotSelection]
|
|
: null
|
|
const isSelected = draftActiveThemeSlot === selection.id
|
|
const isEditing = editingThemeSlot === selection.id
|
|
|
|
return (
|
|
<div
|
|
key={selection.id}
|
|
className={cn (
|
|
'space-y-3 rounded-xl border border-border/70 bg-background/70 p-4',
|
|
isSelected && 'ring-1 ring-primary/30',
|
|
)}>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold">{selection.label}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{themeRowBaseLabel (selection.id)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant={isSelected ? 'default' : 'outline'}
|
|
onClick={() => onSelectThemeSlot (selection.id)}>
|
|
選択
|
|
</Button>
|
|
{selection.editable && (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant={isEditing ? 'default' : 'outline'}
|
|
onClick={() =>
|
|
onEditThemeSlot (selection.id as UserThemeSlotSelection)}>
|
|
編集
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={!(hasUnsavedChanges) || !(isEditing || isSelected)}
|
|
onClick={onThemeSubmit}>
|
|
保存
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() =>
|
|
onResetThemeSlot (selection.id as UserThemeSlotSelection)}>
|
|
既定に戻す
|
|
</Button>
|
|
</>)}
|
|
</div>
|
|
</div>
|
|
|
|
{themeSlot && (
|
|
<div style={themePreviewStyle (themeSlot.tokens.tagColours)}>
|
|
<TagLink
|
|
tag={previewTags.general}
|
|
linkFlg={false}
|
|
truncateOnMobile
|
|
withWiki={false}/>
|
|
</div>)}
|
|
</div>)
|
|
})}
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{themeTokenGroups.map (group => (
|
|
<div
|
|
key={group.title}
|
|
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
|
|
<h3 className="text-sm font-semibold">{group.title}</h3>
|
|
<div className="space-y-3">
|
|
{group.fields.map (field => (
|
|
<div
|
|
key={field.key}
|
|
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
|
|
sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">{field.label}</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<input
|
|
type="color"
|
|
value={hslTokenToHex (selectedTheme.tokens[field.key])}
|
|
onPointerDown={onEnsureEditableTheme}
|
|
onFocus={onEnsureEditableTheme}
|
|
onChange={ev =>
|
|
onThemeTokenChange (field.key, hexToHslToken (ev.target.value))}
|
|
className="h-10 w-16 cursor-pointer rounded border border-border
|
|
bg-transparent p-1"/>
|
|
</div>
|
|
</div>))}
|
|
</div>
|
|
</div>))}
|
|
|
|
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<h3 className="font-medium">タグ</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{CATEGORIES.map (category => (
|
|
<div
|
|
key={category}
|
|
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
|
|
sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
|
|
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
|
|
<TagLink
|
|
tag={previewTags[category]}
|
|
linkFlg={false}
|
|
truncateOnMobile
|
|
withWiki={false}/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<input
|
|
type="color"
|
|
value={selectedTheme.tokens.tagColours[category]}
|
|
onPointerDown={onEnsureEditableTheme}
|
|
onFocus={onEnsureEditableTheme}
|
|
onChange={ev => onTagColourChange (category, ev.target.value)}
|
|
className="h-10 w-16 cursor-pointer rounded border border-border
|
|
bg-transparent p-1"/>
|
|
</div>
|
|
</div>))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>)
|
|
|
|
|
|
const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|
const location = useLocation ()
|
|
const navigate = useNavigate ()
|
|
const dialogue = useDialogue ()
|
|
|
|
const [inheritVsbl, setInheritVsbl] = useState (false)
|
|
const [loading, setLoading] = useState (true)
|
|
const [name, setName] = useState ('')
|
|
const [settingsError, setSettingsError] = useState<string | null> (null)
|
|
const [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
|
|
const [savedActiveThemeSlot, setSavedActiveThemeSlot] =
|
|
useState<ThemeSlotSelection> (getClientActiveThemeSlot ())
|
|
const [draftActiveThemeSlot, setDraftActiveThemeSlot] =
|
|
useState<ThemeSlotSelection> (getClientActiveThemeSlot ())
|
|
const [savedThemeSlots, setSavedThemeSlots] =
|
|
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
|
const [draftThemeSlots, setDraftThemeSlots] =
|
|
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
|
const [editingThemeSlot, setEditingThemeSlot] =
|
|
useState<UserThemeSlotSelection | null> (null)
|
|
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
|
const { conflictActionIds } = useKeyboardShortcutSettings ()
|
|
const { baseErrors: userBaseErrors,
|
|
fieldErrors: userFieldErrors,
|
|
clearValidationErrors: clearUserValidationErrors,
|
|
applyValidationError: applyUserValidationError } = useValidationErrors<UserFormField> ()
|
|
const { baseErrors: settingsBaseErrors,
|
|
fieldErrors: settingsFieldErrors,
|
|
clearValidationErrors: clearSettingsValidationErrors,
|
|
applyValidationError: applySettingsValidationError } =
|
|
useValidationErrors<SettingsFormField> ()
|
|
|
|
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
|
|
const selectedTheme = useMemo<ClientTheme> (
|
|
() => {
|
|
const resolvedTheme =
|
|
resolveThemeFromSlotSelection (draftActiveThemeSlot, draftThemeSlots)
|
|
return {
|
|
id: resolvedTheme.baseTheme,
|
|
name: themeLabelBySelection (draftActiveThemeSlot),
|
|
builtin: resolvedTheme.builtin,
|
|
baseTheme: resolvedTheme.baseTheme,
|
|
tokens: resolvedTheme.tokens,
|
|
}
|
|
},
|
|
[draftActiveThemeSlot, draftThemeSlots],
|
|
)
|
|
const hasUnsavedThemeChanges = useMemo (
|
|
() =>
|
|
serialiseThemeDraft (draftActiveThemeSlot, draftThemeSlots)
|
|
!== serialiseThemeDraft (savedActiveThemeSlot, savedThemeSlots),
|
|
[draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots],
|
|
)
|
|
|
|
const setActiveTab = (tab: SettingsTab) => {
|
|
const params = new URLSearchParams (location.search)
|
|
params.set ('tab', tab)
|
|
navigate (
|
|
`${ location.pathname }?${ params.toString () }`,
|
|
{ replace: true })
|
|
}
|
|
|
|
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
|
|
() => (
|
|
{ account: Boolean (userFieldErrors.name?.length),
|
|
theme: Boolean (settingsFieldErrors.theme?.length),
|
|
keyboard: conflictActionIds.size > 0,
|
|
behavior: false }),
|
|
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name],
|
|
)
|
|
|
|
const applyDraftThemeSelection = (
|
|
activeThemeSlot: ThemeSlotSelection,
|
|
themeSlots: UserThemeSlotMap,
|
|
) => {
|
|
setDraftActiveThemeSlot (activeThemeSlot)
|
|
setDraftThemeSlots (themeSlots)
|
|
setDraftSettings (current => ({
|
|
...current,
|
|
theme: deriveUserThemeModeFromSlotSelection (activeThemeSlot),
|
|
}))
|
|
applyThemeSelection (activeThemeSlot, themeSlots)
|
|
}
|
|
|
|
const confirmThemeSlotSwitch = async (
|
|
onConfirmed: (themeSlots: UserThemeSlotMap) => void,
|
|
): Promise<void> => {
|
|
if (!(hasUnsavedThemeChanges))
|
|
{
|
|
onConfirmed (draftThemeSlots)
|
|
return
|
|
}
|
|
|
|
const confirmed = await dialogue.confirm ({
|
|
title: '未保存の変更があります',
|
|
description: 'このまま切り替えると、保存していない変更は失われます。',
|
|
confirmText: '変更を破棄して切り替える',
|
|
cancelText: 'このまま編輯する',
|
|
})
|
|
|
|
if (!(confirmed))
|
|
return
|
|
|
|
setDraftActiveThemeSlot (savedActiveThemeSlot)
|
|
setDraftThemeSlots (savedThemeSlots)
|
|
setEditingThemeSlot (null)
|
|
onConfirmed (savedThemeSlots)
|
|
}
|
|
|
|
const ensureEditableThemeSlot = async (): Promise<UserThemeSlotSelection | null> => {
|
|
if (editingThemeSlot != null)
|
|
return editingThemeSlot
|
|
if (isUserThemeSlotSelection (draftActiveThemeSlot))
|
|
{
|
|
setEditingThemeSlot (draftActiveThemeSlot)
|
|
return draftActiveThemeSlot
|
|
}
|
|
|
|
const choice = await dialogue.choice<UserThemeSlotSelection> ({
|
|
title: '保存先を選択してください',
|
|
description: '色を変更する前に、保存先のテーマ枠を選択してください。',
|
|
cancelText: '取消',
|
|
choices: themeSelections
|
|
.filter (selection => selection.editable)
|
|
.map (selection => ({
|
|
value: selection.id as UserThemeSlotSelection,
|
|
label: selection.label,
|
|
})),
|
|
})
|
|
|
|
if (choice == null)
|
|
return null
|
|
|
|
setEditingThemeSlot (choice)
|
|
applyDraftThemeSelection (choice, draftThemeSlots)
|
|
return choice
|
|
}
|
|
|
|
const updateDraftThemeSlot = (
|
|
selection: UserThemeSlotSelection,
|
|
updater: (tokens: ThemeTokens) => ThemeTokens,
|
|
options?: { activeThemeSlot?: ThemeSlotSelection },
|
|
) => {
|
|
const currentThemeSlot =
|
|
draftThemeSlots[selection]
|
|
?? ensureCompleteThemeSlots ({ })[selection]
|
|
const nextThemeSlots = {
|
|
...draftThemeSlots,
|
|
[selection]: {
|
|
...currentThemeSlot,
|
|
tokens: updater (currentThemeSlot.tokens),
|
|
},
|
|
}
|
|
|
|
applyDraftThemeSelection (
|
|
options?.activeThemeSlot ?? draftActiveThemeSlot,
|
|
nextThemeSlots,
|
|
)
|
|
}
|
|
|
|
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
const currentIndex = tabs.findIndex (tab => tab.id === activeTab)
|
|
|
|
switch (event.key)
|
|
{
|
|
case 'ArrowDown':
|
|
case 'ArrowRight':
|
|
event.preventDefault ()
|
|
setActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
|
|
break
|
|
|
|
case 'ArrowUp':
|
|
case 'ArrowLeft':
|
|
event.preventDefault ()
|
|
setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
|
|
break
|
|
|
|
case 'Home':
|
|
event.preventDefault ()
|
|
setActiveTab (tabs[0].id)
|
|
break
|
|
|
|
case 'End':
|
|
event.preventDefault ()
|
|
setActiveTab (tabs[tabs.length - 1].id)
|
|
break
|
|
}
|
|
}
|
|
|
|
const handleUserSubmit = async () => {
|
|
if (!(user))
|
|
return
|
|
|
|
clearUserValidationErrors ()
|
|
|
|
const formData = new FormData
|
|
formData.append ('name', name)
|
|
|
|
try
|
|
{
|
|
const data = await apiPut<User> (
|
|
`/users/${ user.id }`, formData,
|
|
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
|
)
|
|
setUser (currentUser => ({ ...currentUser, ...data }))
|
|
toast ({ title: '表示名を更新しました.' })
|
|
}
|
|
catch (submitError)
|
|
{
|
|
applyUserValidationError (submitError)
|
|
toast ({ title: '表示名を更新できませんでした.' })
|
|
}
|
|
}
|
|
|
|
const handleThemeSubmit = async () => {
|
|
clearSettingsValidationErrors ()
|
|
|
|
try
|
|
{
|
|
let nextSavedThemeSlots = { ...savedThemeSlots }
|
|
|
|
for (const selection of USER_THEME_SLOT_SELECTIONS)
|
|
{
|
|
const draftThemeSlot = draftThemeSlots[selection]
|
|
const savedThemeSlot = savedThemeSlots[selection]
|
|
const requiresPersist =
|
|
selection === draftActiveThemeSlot
|
|
&& isUserThemeSlotSelection (draftActiveThemeSlot)
|
|
&& savedThemeSlot?.createdAt == null
|
|
|
|
if (
|
|
JSON.stringify (draftThemeSlot?.tokens)
|
|
=== JSON.stringify (savedThemeSlot?.tokens)
|
|
&& !(requiresPersist)
|
|
)
|
|
continue
|
|
|
|
const persisted = await upsertUserThemeSlot (
|
|
draftThemeSlot?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId,
|
|
draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo,
|
|
draftThemeSlot?.tokens ?? getDefaultUserThemeSlot (
|
|
selection.split (':')[1] as BuiltinThemeId,
|
|
Number (selection.split (':')[2]) as UserThemeSlotNo,
|
|
).tokens,
|
|
)
|
|
nextSavedThemeSlots = {
|
|
...nextSavedThemeSlots,
|
|
[selection]: persisted,
|
|
}
|
|
}
|
|
|
|
const themeMode =
|
|
deriveUserThemeModeFromSlotSelection (draftActiveThemeSlot)
|
|
const data = await updateUserSettings ({ theme: themeMode })
|
|
const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)
|
|
|
|
setDraftSettings ({ ...data, theme: themeMode })
|
|
setSavedActiveThemeSlot (draftActiveThemeSlot)
|
|
setSavedThemeSlots (completeThemeSlots)
|
|
setDraftThemeSlots (completeThemeSlots)
|
|
setEditingThemeSlot (
|
|
isUserThemeSlotSelection (draftActiveThemeSlot)
|
|
? draftActiveThemeSlot
|
|
: null,
|
|
)
|
|
setClientActiveThemeSlot (draftActiveThemeSlot)
|
|
setCachedUserThemeSlots (completeThemeSlots)
|
|
applyThemeSelection (draftActiveThemeSlot, completeThemeSlots)
|
|
toast ({ title: 'テーマ設定を保存しました.' })
|
|
}
|
|
catch (submitError)
|
|
{
|
|
applySettingsValidationError (submitError)
|
|
toast ({ title: 'テーマ設定を保存できませんでした.' })
|
|
}
|
|
}
|
|
|
|
const handleThemeDiscard = () => {
|
|
setDraftActiveThemeSlot (savedActiveThemeSlot)
|
|
setDraftThemeSlots (savedThemeSlots)
|
|
setEditingThemeSlot (
|
|
isUserThemeSlotSelection (savedActiveThemeSlot)
|
|
? savedActiveThemeSlot
|
|
: null,
|
|
)
|
|
applyThemeSelection (savedActiveThemeSlot, savedThemeSlots)
|
|
}
|
|
|
|
useEffect (() => {
|
|
if (!user)
|
|
return
|
|
|
|
setName (user.name ?? '')
|
|
}, [user])
|
|
|
|
useEffect (() => {
|
|
if (!(user))
|
|
return
|
|
|
|
let cancelled = false
|
|
|
|
void (async () => {
|
|
setLoading (true)
|
|
|
|
try
|
|
{
|
|
const [settings, themeSlots] = await Promise.all ([
|
|
fetchUserSettings (),
|
|
fetchUserThemeSlots (),
|
|
])
|
|
if (cancelled)
|
|
return
|
|
|
|
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
|
|
const completeThemeSlots = ensureCompleteThemeSlots (storedThemeSlots)
|
|
const initialSelection = (
|
|
hasStoredClientThemeSelection ()
|
|
? getClientActiveThemeSlot ()
|
|
: themeToSelection (settings.theme)
|
|
)
|
|
const nextSelection = normaliseThemeSlotSelectionAgainstSlots (
|
|
initialSelection,
|
|
storedThemeSlots,
|
|
)
|
|
const themeMode = deriveUserThemeModeFromSlotSelection (nextSelection)
|
|
|
|
setDraftSettings ({ ...settings, theme: themeMode })
|
|
setSavedActiveThemeSlot (nextSelection)
|
|
setDraftActiveThemeSlot (nextSelection)
|
|
setSavedThemeSlots (completeThemeSlots)
|
|
setDraftThemeSlots (completeThemeSlots)
|
|
setEditingThemeSlot (
|
|
isUserThemeSlotSelection (nextSelection)
|
|
? nextSelection
|
|
: null,
|
|
)
|
|
setCachedUserThemeSlots (completeThemeSlots)
|
|
applyThemeSelection (nextSelection, completeThemeSlots)
|
|
if (nextSelection !== initialSelection)
|
|
setClientActiveThemeSlot (nextSelection)
|
|
setSettingsError (null)
|
|
}
|
|
catch
|
|
{
|
|
if (cancelled)
|
|
return
|
|
|
|
setSettingsError ('設定の読込みに失敗しました.')
|
|
}
|
|
finally
|
|
{
|
|
if (!(cancelled))
|
|
setLoading (false)
|
|
}
|
|
}) ()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [user])
|
|
|
|
useEffect (() => {
|
|
if (userFieldErrors.name?.length)
|
|
setActiveTab ('account')
|
|
}, [location.pathname, location.search, userFieldErrors.name])
|
|
|
|
useEffect (() => {
|
|
if (settingsFieldErrors.theme?.length)
|
|
setActiveTab ('theme')
|
|
}, [location.pathname, location.search, settingsFieldErrors.theme])
|
|
|
|
useEffect (() => {
|
|
applyThemeSelection (draftActiveThemeSlot, draftThemeSlots)
|
|
|
|
return () => {
|
|
applyThemeSelection (savedActiveThemeSlot, savedThemeSlots)
|
|
}
|
|
}, [draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots])
|
|
|
|
const accountSectionProps: AccountSectionProps | null =
|
|
user
|
|
? {
|
|
user,
|
|
name,
|
|
onNameChange: setName,
|
|
onInheritOpen: () => setInheritVsbl (true),
|
|
onUserCodeOpen: () => setUserCodeVsbl (true),
|
|
onUserSubmit: handleUserSubmit,
|
|
userBaseErrors,
|
|
userFieldErrors,
|
|
}
|
|
: null
|
|
|
|
const themeSectionProps: ThemeSectionProps = {
|
|
draftActiveThemeSlot,
|
|
draftThemeSlots,
|
|
editingThemeSlot,
|
|
selectedTheme,
|
|
selectedThemeLabel: themeLabelBySelection (draftActiveThemeSlot),
|
|
hasUnsavedChanges: hasUnsavedThemeChanges,
|
|
settingsBaseErrors,
|
|
settingsFieldErrors,
|
|
onSelectThemeSlot: selection => {
|
|
void confirmThemeSlotSwitch (themeSlots => {
|
|
setEditingThemeSlot (null)
|
|
applyDraftThemeSelection (selection, themeSlots)
|
|
})
|
|
},
|
|
onEditThemeSlot: selection => {
|
|
void confirmThemeSlotSwitch (themeSlots => {
|
|
setEditingThemeSlot (selection)
|
|
applyDraftThemeSelection (selection, themeSlots)
|
|
})
|
|
},
|
|
onEnsureEditableTheme: () => {
|
|
void ensureEditableThemeSlot ()
|
|
},
|
|
onThemeTokenChange: (key, value) => {
|
|
void (async () => {
|
|
const editableThemeSlot = await ensureEditableThemeSlot ()
|
|
if (editableThemeSlot == null)
|
|
return
|
|
|
|
updateDraftThemeSlot (editableThemeSlot, tokens => ({
|
|
...tokens,
|
|
[key]: value,
|
|
}), { activeThemeSlot: editableThemeSlot })
|
|
}) ()
|
|
},
|
|
onTagColourChange: (category, colour) => {
|
|
void (async () => {
|
|
const editableThemeSlot = await ensureEditableThemeSlot ()
|
|
if (editableThemeSlot == null)
|
|
return
|
|
|
|
updateDraftThemeSlot (editableThemeSlot, tokens => ({
|
|
...tokens,
|
|
tagColours: {
|
|
...tokens.tagColours,
|
|
[category]: colour,
|
|
},
|
|
}), { activeThemeSlot: editableThemeSlot })
|
|
}) ()
|
|
},
|
|
onResetThemeSlot: selection => {
|
|
const baseTheme = draftThemeSlots[selection]?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId
|
|
updateDraftThemeSlot (
|
|
selection,
|
|
() => getDefaultUserThemeSlot (
|
|
baseTheme,
|
|
draftThemeSlots[selection]?.slotNo
|
|
?? Number (selection.split (':')[2]) as UserThemeSlotNo,
|
|
).tokens,
|
|
)
|
|
},
|
|
onThemeSubmit: handleThemeSubmit,
|
|
onThemeDiscard: handleThemeDiscard,
|
|
}
|
|
|
|
return (
|
|
<MainArea>
|
|
<Helmet>
|
|
<meta name="robots" content="noindex"/>
|
|
<title>設定 | {SITE_TITLE}</title>
|
|
</Helmet>
|
|
|
|
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
|
|
<PageTitle>設定</PageTitle>
|
|
<FieldError messages={settingsError ? [settingsError] : []}/>
|
|
<div
|
|
role="tablist"
|
|
aria-label="設定区分"
|
|
aria-orientation="horizontal"
|
|
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
|
|
{tabs.map (tab => (
|
|
<button
|
|
key={tab.id}
|
|
id={tabButtonId (tab.id)}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === tab.id}
|
|
aria-controls={tabPanelId (tab.id)}
|
|
className={cn (
|
|
'shrink-0 rounded-full border px-3 py-2 text-sm font-medium',
|
|
(activeTab === tab.id
|
|
? ['border-slate-900 bg-slate-900 text-white',
|
|
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
|
|
: ['border-border bg-background text-foreground']),
|
|
)}
|
|
onClick={() => setActiveTab (tab.id)}>
|
|
{tab.label}
|
|
{settingsTabErrors[tab.id] ? ' !' : ''}
|
|
</button>))}
|
|
</div>
|
|
{loading ? 'Loading...' : (
|
|
<div
|
|
id={tabPanelId (activeTab)}
|
|
role="tabpanel"
|
|
aria-labelledby={tabButtonId (activeTab)}>
|
|
{accountSectionProps && activeTab === 'account' && (
|
|
<AccountSection {...accountSectionProps}/>)}
|
|
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
|
|
{activeTab === 'keyboard' && (
|
|
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
|
|
{activeTab === 'behavior' && (
|
|
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
|
|
</div>)}
|
|
</div>
|
|
|
|
<div className="hidden md:flex md:justify-center md:px-4">
|
|
<div className="grid w-full max-w-[58rem] grid-cols-[12rem_minmax(0,1fr)] gap-6">
|
|
<div
|
|
role="tablist"
|
|
aria-label="設定区分"
|
|
aria-orientation="vertical"
|
|
className="sticky top-4 flex h-fit flex-col gap-2">
|
|
{tabs.map (tab => (
|
|
<button
|
|
key={tab.id}
|
|
id={tabButtonId (tab.id)}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === tab.id}
|
|
aria-controls={tabPanelId (tab.id)}
|
|
tabIndex={activeTab === tab.id ? 0 : -1}
|
|
className={cn (
|
|
'rounded-xl border px-4 py-3 text-left text-sm font-medium',
|
|
(activeTab === tab.id
|
|
? ['border-slate-900 bg-slate-900 text-white',
|
|
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
|
|
: ['border-slate-300 bg-white text-slate-700',
|
|
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']),
|
|
)}
|
|
onClick={() => setActiveTab (tab.id)}
|
|
onKeyDown={handleTabKeyDown}>
|
|
{tab.label}
|
|
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
|
|
</button>))}
|
|
</div>
|
|
|
|
<div className="w-full max-w-xl justify-self-center space-y-4">
|
|
<PageTitle>設定</PageTitle>
|
|
<FieldError messages={settingsError ? [settingsError] : []}/>
|
|
{loading ? 'Loading...' : (
|
|
<div
|
|
id={tabPanelId (activeTab)}
|
|
role="tabpanel"
|
|
aria-labelledby={tabButtonId (activeTab)}>
|
|
{activeTab === 'account' && accountSectionProps && (
|
|
<AccountSection {...accountSectionProps}/>)}
|
|
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
|
|
{activeTab === 'keyboard' && (
|
|
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
|
|
{activeTab === 'behavior' && (
|
|
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
|
|
</div>)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UserCodeDialogue
|
|
visible={userCodeVsbl}
|
|
onVisibleChange={setUserCodeVsbl}
|
|
user={user}
|
|
setUser={setUser}/>
|
|
|
|
<InheritDialogue
|
|
visible={inheritVsbl}
|
|
onVisibleChange={setInheritVsbl}
|
|
setUser={setUser}/>
|
|
</MainArea>)
|
|
}
|
|
|
|
export default SettingPage
|