ファイル
btrc-hub/frontend/src/pages/users/SettingPage.tsx
T
2026-07-05 02:40:19 +09:00

852 行
25 KiB
TypeScript
Raw Blame 履歴

このファイルには曖昧(ambiguous)なUnicode文字が含まれてゐます
このファイルには,他の文字と見間違える可能性があるUnicode文字が含まれてゐます. それが意図的なものと考えられる場合は,この警告を無視して構ゐません. それらの文字を表示するにはエスケープボタンを使用します.
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_titleauto_fetch_thumbnailwiki_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