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> } 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> draftActiveThemeId: ActiveThemeId draftCustomThemes: Record 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> } 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 = { 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 = { 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 => { if (themeId === 'system') return themeLabel.system if (themeId === 'light' || themeId === 'dark') return builtinThemeLabel[themeId] return (customThemes[themeId] ?? BUILTIN_THEMES.light).name } const AccountSection: FC = ({ name, onInheritOpen, onNameChange, onUserCodeOpen, onUserSubmit, user, userBaseErrors, userFieldErrors, }) => (

アカウント

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

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

)} )}
) const ThemeSection: FC = ({ draftActiveThemeId, draftCustomThemes, selectedTheme, draftTagColours, onEnsureEditableTheme, onResetAllTagColours, onResetTagColour, onTagColourChange, onThemeChange, onThemeSubmit, settingsBaseErrors, settingsFieldErrors, }) => (

テーマ

{() => ( )}

テーマの選択状態は現在のブラウザに保存し,背景・文字色はそのテーマの base に従います.

backend には system / light / dark の mode だけ保存します. カスタムテーマ本体は今は browser-local です.

将来は themes テーブルなどへ移して,端末間同期できる構造に拡張できます.

タグカテゴリ色

{selectedTheme.builtin || draftActiveThemeId === 'system' ? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.' : `現在の編輯対象: ${ selectedTheme.name }`}

{CATEGORIES.map (category => { const resolvedDefaults = defaultThemeTagColoursFor (selectedTheme.baseTheme) const isDefault = draftTagColours[category] === resolvedDefaults[category] return (
{CATEGORY_NAMES[category]} onTagColourChange (category, ev.target.value)} className="h-10 w-16 cursor-pointer rounded border border-border bg-transparent p-1"/>
) })}

プレビュー

現在の表示テーマ: {themeLabelById (draftActiveThemeId, draftCustomThemes)}

{CATEGORIES.map (category => ( ))}

既存 backend 設定について

auto_fetch_title、auto_fetch_thumbnail、wiki_editor_mode は backend 側に 保持したままです.この共通設定画面にはまだ接続していません.

) const KeyboardSection: FC = () => (

キーボード

現段階では一覧表示のみです.将来は action / key / scope / conflict を持つ key bind 設定へ拡張できる形にしています.

{keyboardShortcutRows.map (row => ( ))}
操作 キー 適用範囲 競合
{row.action} {row.key} {row.scope} {row.conflict}
) const SettingPage: FC = ({ 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 (null) const [, setDraftSettings] = useState (DEFAULT_USER_SETTINGS) const [savedActiveThemeId, setSavedActiveThemeId] = useState (getClientActiveThemeId ()) const [savedCustomThemes, setSavedCustomThemes] = useState> (getClientCustomThemes ()) const [draftActiveThemeId, setDraftActiveThemeId] = useState (getClientActiveThemeId ()) const [draftCustomThemes, setDraftCustomThemes] = useState> (getClientCustomThemes ()) const [userCodeVsbl, setUserCodeVsbl] = useState (false) const { baseErrors: userBaseErrors, fieldErrors: userFieldErrors, clearValidationErrors: clearUserValidationErrors, applyValidationError: applyUserValidationError, } = useValidationErrors () const { baseErrors: settingsBaseErrors, fieldErrors: settingsFieldErrors, clearValidationErrors: clearSettingsValidationErrors, applyValidationError: applySettingsValidationError, } = useValidationErrors () const activeTab = useMemo (() => parseTab (location.search), [location.search]) const selectedTheme = useMemo ( () => 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> (() => ({ account: Boolean (userFieldErrors.name?.length), theme: Boolean (settingsFieldErrors.theme?.length), keyboard: false, }), [settingsFieldErrors.theme, userFieldErrors.name]) const applyDraftThemeSelection = ( activeThemeId: ActiveThemeId, customThemes: Record, ) => { setDraftActiveThemeId (activeThemeId) setDraftCustomThemes (customThemes) setDraftSettings (current => ({ ...current, theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes), })) applyThemeSelection (activeThemeId, customThemes) } const ensureEditableCustomTheme = (): { activeThemeId: ActiveThemeId customThemes: Record } => { 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 } => { const editable = ensureEditableCustomTheme () if (editable.activeThemeId !== draftActiveThemeId || editable.customThemes !== draftCustomThemes) { applyDraftThemeSelection (editable.activeThemeId, editable.customThemes) } return editable } const handleTabKeyDown = (event: KeyboardEvent) => { const currentIndex = tabs.findIndex (tab => tab.id === activeTab) switch (event.key) { case 'ArrowDown': case 'ArrowRight': event.preventDefault () setActiveTab (tabs[(currentIndex + 1) % tabs.length].id) break case 'ArrowUp': case 'ArrowLeft': event.preventDefault () setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id) break case 'Home': event.preventDefault () setActiveTab (tabs[0].id) break case 'End': event.preventDefault () setActiveTab (tabs[tabs.length - 1].id) break } } const handleUserSubmit = async () => { if (!(user)) return clearUserValidationErrors () const formData = new FormData formData.append ('name', name) try { const data = await apiPut ( `/users/${ user.id }`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }) setUser (currentUser => ({ ...currentUser, ...data })) toast ({ title: '表示名を更新しました.' }) } catch (submitError) { applyUserValidationError (submitError) toast ({ title: '表示名を更新できませんでした.' }) } } const handleThemeSubmit = async () => { clearSettingsValidationErrors () try { 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 ( 設定 | {SITE_TITLE}
設定 {loading ? 'Loading...' : ( <> {sectionProps && ( <> )} )}
{tabs.map (tab => ( ))}
設定 {loading ? 'Loading...' : (
{sectionProps && ( <> {activeTab === 'account' && } {activeTab === 'theme' && } {activeTab === 'keyboard' && } )}
)}
) } export default SettingPage