852 行
25 KiB
TypeScript
852 行
25 KiB
TypeScript
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
|
||
|
||
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 InheritDialogue from '@/components/users/InheritDialogue'
|
||
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
|
||
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
|
||
import { Button } from '@/components/ui/button'
|
||
import { toast } from '@/components/ui/use-toast'
|
||
import { SITE_TITLE } from '@/config'
|
||
import { apiPut } from '@/lib/api'
|
||
import {
|
||
applyThemeSelection,
|
||
BUILTIN_THEMES,
|
||
createCustomThemeFromBase,
|
||
defaultThemeTagColoursFor,
|
||
DEFAULT_USER_SETTINGS,
|
||
deriveUserThemeModeFromSelection,
|
||
fetchUserSettings,
|
||
hasStoredClientThemeSelection,
|
||
getClientActiveThemeId,
|
||
getClientCustomThemes,
|
||
getResolvedThemeFromSelection,
|
||
setClientAppearanceThemes,
|
||
updateUserSettings,
|
||
} from '@/lib/settings'
|
||
import { cn, inputClass } from '@/lib/utils'
|
||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||
|
||
import type { Category, Tag, User } from '@/types'
|
||
import type {
|
||
ActiveThemeId,
|
||
ClientTheme,
|
||
CustomClientTheme,
|
||
ThemeTagColours,
|
||
UserSettings,
|
||
} from '@/lib/settings'
|
||
|
||
type Props = {
|
||
user: User | null
|
||
setUser: Dispatch<SetStateAction<User | null>>
|
||
}
|
||
|
||
type UserFormField = 'name'
|
||
type SettingsFormField = 'theme'
|
||
type SettingsTab = 'account' | 'theme' | 'keyboard'
|
||
|
||
type TabSpec = {
|
||
id: SettingsTab
|
||
label: string }
|
||
|
||
type SharedSectionProps = {
|
||
user: User
|
||
name: string
|
||
onNameChange: (value: string) => void
|
||
onInheritOpen: () => void
|
||
onUserCodeOpen: () => void
|
||
onUserSubmit: () => void
|
||
userBaseErrors: string[]
|
||
userFieldErrors: Partial<Record<UserFormField, string[]>>
|
||
draftActiveThemeId: ActiveThemeId
|
||
draftCustomThemes: Record<string, CustomClientTheme>
|
||
selectedTheme: ClientTheme
|
||
draftTagColours: ThemeTagColours
|
||
onThemeChange: (themeId: ActiveThemeId) => void
|
||
onEnsureEditableTheme: () => void
|
||
onTagColourChange: (category: Category, colour: string) => void
|
||
onResetAllTagColours: () => void
|
||
onResetTagColour: (category: Category) => void
|
||
onThemeSubmit: () => void
|
||
settingsBaseErrors: string[]
|
||
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
|
||
}
|
||
|
||
const tabs: TabSpec[] = [
|
||
{ id: 'account', label: 'アカウント' },
|
||
{ id: 'theme', label: 'テーマ' },
|
||
{ id: 'keyboard', label: 'キーボード' },
|
||
]
|
||
|
||
const sectionClassName =
|
||
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
||
|
||
const themeLabel: Record<UserSettings['theme'], string> = {
|
||
system: 'システム設定に従う',
|
||
light: 'ライト・モード',
|
||
dark: 'ダーク・モード',
|
||
}
|
||
|
||
const builtinThemeLabel: Record<'light' | 'dark', string> = {
|
||
light: 'ライト・モード',
|
||
dark: 'ダーク・モード',
|
||
}
|
||
|
||
const keyboardShortcutRows = [
|
||
{
|
||
action: '投稿詳細を開く',
|
||
key: 'Enter',
|
||
scope: '一覧・検索結果',
|
||
conflict: 'なし',
|
||
},
|
||
{
|
||
action: '候補タグを選ぶ',
|
||
key: '↑ / ↓ / Enter',
|
||
scope: 'タグ入力',
|
||
conflict: 'なし',
|
||
},
|
||
{
|
||
action: '候補タグを閉じる',
|
||
key: 'Escape',
|
||
scope: 'タグ入力',
|
||
conflict: 'なし',
|
||
},
|
||
]
|
||
|
||
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 themeLabelById = (
|
||
themeId: ActiveThemeId,
|
||
customThemes: Record<string, CustomClientTheme>,
|
||
): string => {
|
||
if (themeId === 'system')
|
||
return themeLabel.system
|
||
if (themeId === 'light' || themeId === 'dark')
|
||
return builtinThemeLabel[themeId]
|
||
return (customThemes[themeId] ?? BUILTIN_THEMES.light).name
|
||
}
|
||
|
||
|
||
const AccountSection: FC<SharedSectionProps> = ({
|
||
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<SharedSectionProps> = ({
|
||
draftActiveThemeId,
|
||
draftCustomThemes,
|
||
selectedTheme,
|
||
draftTagColours,
|
||
onEnsureEditableTheme,
|
||
onResetAllTagColours,
|
||
onResetTagColour,
|
||
onTagColourChange,
|
||
onThemeChange,
|
||
onThemeSubmit,
|
||
settingsBaseErrors,
|
||
settingsFieldErrors,
|
||
}) => (
|
||
<section className={sectionClassName}>
|
||
<h2 className="text-xl font-bold">テーマ</h2>
|
||
<FieldError messages={settingsBaseErrors}/>
|
||
|
||
<FormField label="基本テーマ" messages={settingsFieldErrors.theme}>
|
||
{() => (
|
||
<select
|
||
className={inputClass (Boolean (settingsFieldErrors.theme?.length))}
|
||
value={draftActiveThemeId}
|
||
onChange={ev => onThemeChange (ev.target.value as ActiveThemeId)}>
|
||
<option value="system">{themeLabel.system}</option>
|
||
<option value="light">{builtinThemeLabel.light}</option>
|
||
<option value="dark">{builtinThemeLabel.dark}</option>
|
||
{Object.values (draftCustomThemes).length > 0 && (
|
||
<optgroup label="カスタムテーマ">
|
||
{Object.values (draftCustomThemes).map (theme => (
|
||
<option key={theme.id} value={theme.id}>
|
||
{theme.name}
|
||
</option>))}
|
||
</optgroup>)}
|
||
</select>)}
|
||
</FormField>
|
||
|
||
<div className="space-y-2 rounded-lg border border-dashed border-border/70 p-3 text-sm">
|
||
<p>
|
||
テーマの選択状態は現在のブラウザに保存し,背景・文字色はそのテーマの base に従います.
|
||
</p>
|
||
<p className="text-muted-foreground">
|
||
backend には system / light / dark の mode だけ保存します.
|
||
カスタムテーマ本体は今は browser-local です.
|
||
</p>
|
||
<p className="text-muted-foreground">
|
||
将来は themes テーブルなどへ移して,端末間同期できる構造に拡張できます.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div>
|
||
<h3 className="font-medium">タグカテゴリ色</h3>
|
||
<p className="text-sm text-muted-foreground">
|
||
{selectedTheme.builtin || draftActiveThemeId === 'system'
|
||
? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.'
|
||
: `現在の編輯対象: ${ selectedTheme.name }`}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={onResetAllTagColours}
|
||
disabled={selectedTheme.builtin || draftActiveThemeId === 'system'}>
|
||
すべてのタグ色を既定に戻す
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{CATEGORIES.map (category => {
|
||
const resolvedDefaults =
|
||
defaultThemeTagColoursFor (selectedTheme.baseTheme)
|
||
const isDefault =
|
||
draftTagColours[category] === resolvedDefaults[category]
|
||
|
||
return (
|
||
<div
|
||
key={category}
|
||
className="space-y-2 rounded-lg border border-border/70 px-3 py-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-sm">{CATEGORY_NAMES[category]}</span>
|
||
<input
|
||
type="color"
|
||
value={draftTagColours[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 className="flex justify-end">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="h-auto px-0 py-0 text-xs"
|
||
onClick={() => onResetTagColour (category)}
|
||
disabled={
|
||
isDefault
|
||
|| selectedTheme.builtin
|
||
|| draftActiveThemeId === 'system'
|
||
}>
|
||
既定に戻す
|
||
</Button>
|
||
</div>
|
||
</div>)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2 rounded-lg border border-border bg-background p-3
|
||
text-foreground shadow-sm transition-colors">
|
||
<p className="text-sm font-medium">プレビュー</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
現在の表示テーマ: {themeLabelById (draftActiveThemeId, draftCustomThemes)}
|
||
</p>
|
||
<div className="flex flex-wrap gap-3" style={themePreviewStyle (draftTagColours)}>
|
||
{CATEGORIES.map (category => (
|
||
<TagLink
|
||
key={category}
|
||
tag={previewTags[category]}
|
||
linkFlg={false}
|
||
truncateOnMobile
|
||
withWiki={false}/>))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2 rounded-lg border border-dashed border-border/70 p-3 text-sm">
|
||
<p className="font-medium">既存 backend 設定について</p>
|
||
<p>
|
||
auto_fetch_title、auto_fetch_thumbnail、wiki_editor_mode は backend 側に
|
||
保持したままです.この共通設定画面にはまだ接続していません.
|
||
</p>
|
||
</div>
|
||
|
||
<Button type="button" onClick={onThemeSubmit}>
|
||
テーマを保存
|
||
</Button>
|
||
</section>)
|
||
|
||
|
||
const KeyboardSection: FC = () => (
|
||
<section className={sectionClassName}>
|
||
<h2 className="text-xl font-bold">キーボード</h2>
|
||
|
||
<p className="text-sm text-muted-foreground">
|
||
現段階では一覧表示のみです.将来は action / key / scope / conflict を持つ
|
||
key bind 設定へ拡張できる形にしています.
|
||
</p>
|
||
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[28rem] table-fixed border-collapse text-sm">
|
||
<thead className="border-b border-border">
|
||
<tr>
|
||
<th className="p-2 text-left">操作</th>
|
||
<th className="p-2 text-left">キー</th>
|
||
<th className="p-2 text-left">適用範囲</th>
|
||
<th className="p-2 text-left">競合</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{keyboardShortcutRows.map (row => (
|
||
<tr key={row.action} className="border-b border-border/60 last:border-b-0">
|
||
<td className="p-2">{row.action}</td>
|
||
<td className="p-2 font-mono">{row.key}</td>
|
||
<td className="p-2">{row.scope}</td>
|
||
<td className="p-2">{row.conflict}</td>
|
||
</tr>))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>)
|
||
|
||
|
||
const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||
const location = useLocation ()
|
||
const navigate = useNavigate ()
|
||
|
||
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 [savedActiveThemeId, setSavedActiveThemeId] =
|
||
useState<ActiveThemeId> (getClientActiveThemeId ())
|
||
const [savedCustomThemes, setSavedCustomThemes] =
|
||
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
|
||
const [draftActiveThemeId, setDraftActiveThemeId] =
|
||
useState<ActiveThemeId> (getClientActiveThemeId ())
|
||
const [draftCustomThemes, setDraftCustomThemes] =
|
||
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
|
||
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
||
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> (
|
||
() => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes),
|
||
[draftActiveThemeId, draftCustomThemes],
|
||
)
|
||
const draftTagColours = selectedTheme.tokens.tagColours
|
||
|
||
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: false,
|
||
}), [settingsFieldErrors.theme, userFieldErrors.name])
|
||
|
||
const applyDraftThemeSelection = (
|
||
activeThemeId: ActiveThemeId,
|
||
customThemes: Record<string, CustomClientTheme>,
|
||
) => {
|
||
setDraftActiveThemeId (activeThemeId)
|
||
setDraftCustomThemes (customThemes)
|
||
setDraftSettings (current => ({
|
||
...current,
|
||
theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes),
|
||
}))
|
||
applyThemeSelection (activeThemeId, customThemes)
|
||
}
|
||
|
||
const ensureEditableCustomTheme = (): {
|
||
activeThemeId: ActiveThemeId
|
||
customThemes: Record<string, CustomClientTheme>
|
||
} => {
|
||
if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null)
|
||
{
|
||
return {
|
||
activeThemeId: draftActiveThemeId,
|
||
customThemes: draftCustomThemes,
|
||
}
|
||
}
|
||
|
||
const customTheme = createCustomThemeFromBase (
|
||
selectedTheme.baseTheme,
|
||
{
|
||
...selectedTheme.tokens,
|
||
tagColours: { ...selectedTheme.tokens.tagColours },
|
||
},
|
||
)
|
||
const nextCustomThemes = {
|
||
...draftCustomThemes,
|
||
[customTheme.id]: customTheme,
|
||
}
|
||
|
||
return {
|
||
activeThemeId: customTheme.id,
|
||
customThemes: nextCustomThemes,
|
||
}
|
||
}
|
||
|
||
const handleEnsureEditableTheme = (): {
|
||
activeThemeId: ActiveThemeId
|
||
customThemes: Record<string, CustomClientTheme>
|
||
} => {
|
||
const editable = ensureEditableCustomTheme ()
|
||
|
||
if (editable.activeThemeId !== draftActiveThemeId
|
||
|| editable.customThemes !== draftCustomThemes)
|
||
{
|
||
applyDraftThemeSelection (editable.activeThemeId, editable.customThemes)
|
||
}
|
||
|
||
return editable
|
||
}
|
||
|
||
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
|
||
{
|
||
const themeMode =
|
||
deriveUserThemeModeFromSelection (draftActiveThemeId, draftCustomThemes)
|
||
const data = await updateUserSettings ({ theme: themeMode })
|
||
setDraftSettings ({ ...data, theme: themeMode })
|
||
setSavedActiveThemeId (draftActiveThemeId)
|
||
setSavedCustomThemes (draftCustomThemes)
|
||
setClientAppearanceThemes (draftActiveThemeId, draftCustomThemes)
|
||
applyThemeSelection (draftActiveThemeId, draftCustomThemes)
|
||
toast ({ title: 'テーマ設定を保存しました.' })
|
||
}
|
||
catch (submitError)
|
||
{
|
||
applySettingsValidationError (submitError)
|
||
toast ({ title: 'テーマ設定を保存できませんでした.' })
|
||
}
|
||
}
|
||
|
||
useEffect (() => {
|
||
if (!user)
|
||
return
|
||
|
||
setName (user.name ?? '')
|
||
}, [user])
|
||
|
||
useEffect (() => {
|
||
if (!(user))
|
||
return
|
||
|
||
let cancelled = false
|
||
|
||
void (async () => {
|
||
setLoading (true)
|
||
|
||
try
|
||
{
|
||
const data = await fetchUserSettings ()
|
||
if (cancelled)
|
||
return
|
||
|
||
const customThemes = getClientCustomThemes ()
|
||
const activeThemeId = hasStoredClientThemeSelection ()
|
||
? getClientActiveThemeId ()
|
||
: data.theme
|
||
const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes)
|
||
|
||
setDraftSettings ({ ...data, theme: themeMode })
|
||
setSavedActiveThemeId (activeThemeId)
|
||
setSavedCustomThemes (customThemes)
|
||
setDraftActiveThemeId (activeThemeId)
|
||
setDraftCustomThemes (customThemes)
|
||
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 (draftActiveThemeId, draftCustomThemes)
|
||
|
||
return () => {
|
||
applyThemeSelection (savedActiveThemeId, savedCustomThemes)
|
||
}
|
||
}, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes])
|
||
|
||
const sectionProps: SharedSectionProps | null = user ? {
|
||
user,
|
||
name,
|
||
onNameChange: setName,
|
||
onInheritOpen: () => setInheritVsbl (true),
|
||
onUserCodeOpen: () => setUserCodeVsbl (true),
|
||
onUserSubmit: handleUserSubmit,
|
||
userBaseErrors,
|
||
userFieldErrors,
|
||
draftActiveThemeId,
|
||
draftCustomThemes,
|
||
selectedTheme,
|
||
draftTagColours,
|
||
onThemeChange: themeId => {
|
||
applyDraftThemeSelection (themeId, draftCustomThemes)
|
||
},
|
||
onEnsureEditableTheme: () => {
|
||
handleEnsureEditableTheme ()
|
||
},
|
||
onTagColourChange: (category, colour) => {
|
||
const editable = handleEnsureEditableTheme ()
|
||
const editableTheme = editable.customThemes[editable.activeThemeId]
|
||
const nextCustomThemes = {
|
||
...editable.customThemes,
|
||
[editable.activeThemeId]: {
|
||
...editableTheme,
|
||
tokens: {
|
||
...editableTheme.tokens,
|
||
tagColours: {
|
||
...editableTheme.tokens.tagColours,
|
||
[category]: colour,
|
||
},
|
||
},
|
||
},
|
||
}
|
||
applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes)
|
||
},
|
||
onResetAllTagColours: () => {
|
||
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
||
return
|
||
|
||
applyDraftThemeSelection (
|
||
draftActiveThemeId,
|
||
{
|
||
...draftCustomThemes,
|
||
[draftActiveThemeId]: {
|
||
...draftCustomThemes[draftActiveThemeId],
|
||
tokens: {
|
||
...draftCustomThemes[draftActiveThemeId].tokens,
|
||
tagColours: {
|
||
...defaultThemeTagColoursFor (selectedTheme.baseTheme),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
)
|
||
},
|
||
onResetTagColour: category => {
|
||
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
||
return
|
||
|
||
applyDraftThemeSelection (
|
||
draftActiveThemeId,
|
||
{
|
||
...draftCustomThemes,
|
||
[draftActiveThemeId]: {
|
||
...draftCustomThemes[draftActiveThemeId],
|
||
tokens: {
|
||
...draftCustomThemes[draftActiveThemeId].tokens,
|
||
tagColours: {
|
||
...draftCustomThemes[draftActiveThemeId].tokens.tagColours,
|
||
[category]:
|
||
defaultThemeTagColoursFor (selectedTheme.baseTheme)[category],
|
||
},
|
||
},
|
||
},
|
||
},
|
||
)
|
||
},
|
||
onThemeSubmit: handleThemeSubmit,
|
||
settingsBaseErrors,
|
||
settingsFieldErrors,
|
||
} : null
|
||
|
||
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] : []}/>
|
||
{loading ? 'Loading...' : (
|
||
<>
|
||
{sectionProps && (
|
||
<>
|
||
<AccountSection {...sectionProps}/>
|
||
<ThemeSection {...sectionProps}/>
|
||
<KeyboardSection/>
|
||
</>)}
|
||
</>)}
|
||
</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)}>
|
||
{sectionProps && (
|
||
<>
|
||
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
|
||
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
|
||
{activeTab === 'keyboard' && <KeyboardSection/>}
|
||
</>)}
|
||
</div>)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<UserCodeDialogue
|
||
visible={userCodeVsbl}
|
||
onVisibleChange={setUserCodeVsbl}
|
||
user={user}
|
||
setUser={setUser}/>
|
||
|
||
<InheritDialogue
|
||
visible={inheritVsbl}
|
||
onVisibleChange={setInheritVsbl}
|
||
setUser={setUser}/>
|
||
</MainArea>)
|
||
}
|
||
|
||
export default SettingPage
|