From f4dea9b91efb59f7cdc349e22a2f93c109df96e9 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 6 Jul 2026 19:34:11 +0900 Subject: [PATCH] #34 --- AGENTS.md | 111 ++++++ backend/db/schema.rb | 6 - .../components/users/UserSettingsProvider.tsx | 97 ----- frontend/src/index.css | 25 +- frontend/src/lib/settings.ts | 5 + frontend/src/pages/users/SettingPage.tsx | 351 +++++++++--------- 6 files changed, 290 insertions(+), 305 deletions(-) delete mode 100644 frontend/src/components/users/UserSettingsProvider.tsx diff --git a/AGENTS.md b/AGENTS.md index 8c478cc..2c1f926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -362,6 +362,20 @@ const value = - Do not use a leading semicolon for expression statements such as `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards explicitly, for example with `void`. +- Do not insert `void` mechanically before ordinary event-handler or callback + calls merely because the callee returns a `Promise`. If the surrounding code + already uses a plain expression statement such as + `onClick={() => requestActiveTab ('theme')}`, preserve that style unless a + local rule or type requirement truly forces `void`. Adding unnecessary + `void` is a medium-severity style violation. +- When fixing formatting, never change operator binding, evaluation order, + tuple/comma behaviour, or any other expression structure. A formatting pass + that changes how `!==`, `??`, `?:`, `&&`, `||`, or `,` binds is a capital + offence. +- In TypeScript and TSX, do not mechanically verticalise short call tails, + short object literals, or short callback bodies that already fit the local + line-length and delimiter rules. Expanding compact local code without a + readability need is a minor style violation. - Use correct British English spelling for new identifiers, filenames, component names, helper names, comments, and developer-facing prose unless editing an already established American-English API that must keep its @@ -380,6 +394,82 @@ const value = - For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not `BehaviorSettingsSection.tsx`. +### Additional style offences and penalties + +Use this section for recurrent failure modes that are easy to miss when +applying the delimiter rules mechanically. + +#### Unnecessary `void` before ordinary callbacks + +Bad: + +```ts +onClick={() => void requestActiveTab ('theme')} +``` + +Good: + +```ts +onClick={() => requestActiveTab ('theme')} +``` + +Penalty: medium-severity offence. The code still works, but it pollutes the +local style and usually signals that the edit was made mechanically rather than +by reading the surrounding code. + +#### Formatting that changes expression structure + +Bad: + +```ts +const hasUnsavedChanges = + serialiseThemeDraft (draftThemeSlots) + !== (serialiseThemeDraft (savedThemeSlots), + draftActiveThemeMode !== savedThemeMode) +``` + +Good: + +```ts +const hasUnsavedChanges = + serialiseThemeDraft (draftThemeSlots) + !== serialiseThemeDraft (savedThemeSlots) + || draftThemeMode !== savedThemeMode +``` + +Penalty: capital offence. Formatting must never corrupt the meaning of an +expression. + +#### Mechanical vertical expansion of compact local code + +Bad: + +```ts +const nextState = { + open, + activeTab } + +setDirtyStates ( + current => ({ + current, + key: value }), +) +``` + +Good: + +```ts +const nextState = { open, activeTab } + +setDirtyStates ( + current => ({ + current, + key: value })) +``` + +Penalty: minor offence when semantics stay intact. It is not necessarily +broken, but it ignores the repository's compact associative style. + ### Frontend delimiter decision table Use this table before accepting any edited TypeScript or TSX hunk. The table is @@ -412,6 +502,7 @@ import { Button, Card } from '@/components/ui' Rule: named-binding `}` is associative-array-style syntax. It must not be alone at the beginning of a line. Prefer keeping `{` with the first binding and `}` with the final binding when this fits the line limit. +Penalty: capital offence. This is one of the core associative-brace rules. #### Type literals @@ -434,6 +525,8 @@ type Props = { Rule: type-literal `}` is not a block close. It must stay on the same line as the final property unless that would break the hard line limit. +Penalty: capital offence. Mistaking a type-literal `}` for a block `}` is one +of the recurring death-class errors. #### Object literals @@ -474,6 +567,8 @@ const value = useMemo (() => ({ Rule: object-literal `}` is associative-array-style syntax. It must not be on a line by itself. Keep it with the final property, and keep call `)` off the beginning of a line. +Penalty: capital offence. Wrong object-literal `}` placement and wrong call +`)` placement are both death-class violations here. #### Destructuring parameters @@ -504,6 +599,8 @@ that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>` shape. Put the function parameter list on its own lines. The destructuring `}` stays with the final binding. The parameter-list `)` is then allowed and required at the beginning of its own line. +Penalty: capital offence. This pattern is a primary source of repeated TSX +style regressions. #### Inline typed destructuring parameter @@ -531,6 +628,8 @@ const RouteTransitionWrapper = ({ user, setUser }: { Rule: this is not a separately split parameter-list block. The line break is inside the inline type. Keep the type-literal `}` and parameter-list `)` on the same line as the final type property. +Penalty: capital offence. Misclassifying this case means the delimiter review +has failed at the syntax-role level. #### Multi-line normal parameter list @@ -558,6 +657,8 @@ const updateDraft = ( Rule: when the parameter list itself is split across multiple parameter lines, the closing parameter `)` goes at the beginning of its own line before `=>` or the return type. +Penalty: capital offence. This is the main allowed exception for a leading `)`, +so getting it wrong destroys the whole rule set. #### Function and callback blocks @@ -586,6 +687,8 @@ const handleSave = () => { Rule: block `}` closes executable code, not associative data. Multi-line function, lambda, callback, `if`, `for`, `switch`, and similar block braces belong on their own line. +Penalty: capital offence. Confusing block `}` with associative `}` is a +death-class delimiter error. #### Function and method calls @@ -609,6 +712,8 @@ const value = compute ( Rule: call-expression `)` must not be at the beginning of a line. The exception for leading `)` applies only to function declaration parameter lists, never to calls. +Penalty: capital offence. A leading call `)` is one of the clearest death-class +violations in this codebase. #### JSX closing markers @@ -653,6 +758,8 @@ Good: Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in the local compact form such as `)`. +Penalty: medium-severity offence. It is usually not semantically broken, but it +is a conspicuous TSX-style failure. #### Arrays and tuples @@ -681,6 +788,8 @@ const items = [first, second] Rule: array and tuple `]` must not be at the beginning of a line. Keep it with the final element unless that would break the hard line limit. +Penalty: capital offence. A leading `]` is forbidden by the same class of hard +delimiter rule as leading associative `}` and leading call `)`. #### Single-line braces @@ -705,6 +814,8 @@ Good: Rule: JavaScript object braces on one line get one inner space. JSX expression braces do not get inner spaces. +Penalty: minor offence. This is not normally semantic breakage, but it still +counts as a style miss. #### Final TypeScript/TSX self-review checklist diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 04ab55e..9df940f 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -371,12 +371,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "theme", default: "system", null: false - t.string "display_density", default: "comfortable", null: false - t.string "font_size", default: "normal", null: false - t.integer "post_list_limit", default: 50, null: false - t.string "post_list_order", default: "created_at_desc", null: false - t.string "viewed_post_display", default: "show", null: false - t.boolean "tag_autocomplete_nico", default: true, null: false t.string "auto_fetch_title", default: "manual", null: false t.string "auto_fetch_thumbnail", default: "manual", null: false t.string "wiki_editor_mode", default: "split", null: false diff --git a/frontend/src/components/users/UserSettingsProvider.tsx b/frontend/src/components/users/UserSettingsProvider.tsx deleted file mode 100644 index 3966f59..0000000 --- a/frontend/src/components/users/UserSettingsProvider.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { createContext, useContext, useEffect, useMemo, useState } from 'react' - -import { - applyClientAppearance, - DEFAULT_USER_SETTINGS, - fetchUserSettings, -} from '@/lib/settings' - -import type { Dispatch, FC, ReactNode, SetStateAction } from 'react' - -import type { User } from '@/types' -import type { UserSettings } from '@/lib/settings' - -type ContextValue = { - loaded: boolean - error: string | null - settings: UserSettings - setSettings: Dispatch> } - -const UserSettingsContext = createContext (null) - - -export const UserSettingsProvider: FC<{ - children: ReactNode - user: User | null }> = ({ children, user }) => { - const [error, setError] = useState (null) - const [loaded, setLoaded] = useState (false) - const [settings, setSettings] = useState (DEFAULT_USER_SETTINGS) - - useEffect (() => { - let cancelled = false - - if (!(user)) - { - setError (null) - setSettings (DEFAULT_USER_SETTINGS) - setLoaded (true) - return - } - - setLoaded (false) - setError (null) - - void (async () => { - try - { - const next = await fetchUserSettings () - if (!(cancelled)) - { - setError (null) - setSettings (next) - setLoaded (true) - } - } - catch - { - if (!(cancelled)) - { - setError ('設定を読み込めませんでした.既定値で表示しています.') - setSettings (DEFAULT_USER_SETTINGS) - setLoaded (true) - } - } - }) () - - return () => { - cancelled = true - } - }, [user]) - - useEffect (() => { - void settings.theme - applyClientAppearance () - }, [settings.theme]) - - const value = useMemo (() => ({ - error, - loaded, - settings, - setSettings, - }), [error, loaded, settings]) - - return ( - - {children} - ) -} - - -export const useUserSettings = (): ContextValue => { - const value = useContext (UserSettingsContext) - - if (value == null) - throw new Error ('UserSettingsProvider is missing') - - return value -} diff --git a/frontend/src/index.css b/frontend/src/index.css index 3ae212b..bed2346 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -105,7 +105,7 @@ } a:hover { - opacity: .85; + color: color-mix(in oklab, hsl(var(--theme-link)), white 18%); } } @@ -125,29 +125,6 @@ -moz-osx-font-smoothing: grayscale; } -:root[data-font-size='small'] -{ - font-size: 14px; -} - -:root[data-font-size='normal'] -{ - font-size: 16px; -} - -:root[data-font-size='large'] -{ - font-size: 18px; -} - -:root[data-display-density='compact'] input, -:root[data-display-density='compact'] textarea, -:root[data-display-density='compact'] select, -:root[data-display-density='compact'] button -{ - line-height: 1.25; -} - :root[data-animation='reduced'] *, :root[data-animation='off'] * { diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 8f666f6..adaa01b 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -721,6 +721,10 @@ const mixHexColour = ( } +// Legacy appearance-model compatibility. +// The current settings UI uses `themeMode` plus the light/dark slot numbers. +// The helpers below remain only to read older localStorage shapes. + const legacyThemeSlotSelection = (): ThemeSlotSelection => { const appearance = appearanceFromSettings () const customThemes = getClientCustomThemes () @@ -774,6 +778,7 @@ export const getClientActiveThemeSlot = (): ThemeSlotSelection => ?? legacyThemeSlotSelection () +// Legacy write path. New code should use `setClientThemeAppearance`. export const setClientActiveThemeSlot = ( activeThemeSlot: ThemeSlotSelection, ): void => { diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 6c30086..0c727eb 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocation, useNavigate } from 'react-router-dom' +import { useBeforeUnload, useBlocker, useLocation, useNavigate } from 'react-router-dom' import FieldError from '@/components/common/FieldError' import FormField from '@/components/common/FormField' @@ -21,7 +21,6 @@ import { apiPut } from '@/lib/api' import { applyClientAppearance, applyThemeAppearanceState, - DEFAULT_USER_SETTINGS, fetchUserSettings, fetchUserThemeSlots, getClientActiveDarkThemeSlotNo, @@ -34,7 +33,6 @@ import { resolveDisplayedTheme, setCachedUserThemeSlots, setClientThemeAppearance, - updateUserSettings, upsertUserThemeSlot, USER_THEME_SLOT_SELECTIONS, } from '@/lib/settings' @@ -51,7 +49,6 @@ import type { ClientTheme, ThemeTokens, ThemeTagColours, - UserSettings, UserThemeSlotMap, UserThemeSlotNo, UserThemeSlotSelection, @@ -224,6 +221,7 @@ const parseTab = (search: string): SettingsTab => { const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }` const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }` +const SETTINGS_PATH = '/users/settings' const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties => Object.fromEntries ( @@ -679,7 +677,6 @@ const SettingPage: FC = ({ user, setUser }) => { const [loading, setLoading] = useState (true) const [name, setName] = useState ('') const [settingsError, setSettingsError] = useState (null) - const [, setDraftSettings] = useState (DEFAULT_USER_SETTINGS) const [savedThemeMode, setSavedThemeMode] = useState (getClientThemeMode ()) const [draftThemeMode, setDraftThemeMode] = @@ -719,45 +716,37 @@ const SettingPage: FC = ({ user, setUser }) => { () => resolveDisplayedTheme ({ themeMode: draftThemeMode, activeLightThemeSlotNo: draftActiveLightThemeSlotNo, - activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, - }, draftThemeSlots), + activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots), [draftActiveDarkThemeSlotNo, draftActiveLightThemeSlotNo, draftThemeMode, - draftThemeSlots], - ) + draftThemeSlots]) const selectedTheme = useMemo ( () => { - const currentSelection = - themeSelections.find (selection => selection.id === editingThemeSlot) - ?? themeSelections[0] - const themeSlot = - draftThemeSlots[currentSelection.id] - ?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo) - return { - id: currentSelection.baseTheme, - name: themeLabelBySelection (currentSelection.id), - builtin: false, - baseTheme: currentSelection.baseTheme, - tokens: themeSlot.tokens, - } + const currentSelection = + themeSelections.find (selection => selection.id === editingThemeSlot) + ?? themeSelections[0] + const themeSlot = + draftThemeSlots[currentSelection.id] + ?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo) + return { + id: currentSelection.baseTheme, + name: themeLabelBySelection (currentSelection.id), + builtin: false, + baseTheme: currentSelection.baseTheme, + tokens: themeSlot.tokens } }, - [draftThemeSlots, editingThemeSlot], - ) + [draftThemeSlots, editingThemeSlot]) const hasUnsavedThemeChanges = useMemo ( () => - serialiseThemeDraft ( - draftThemeMode, - draftActiveLightThemeSlotNo, - draftActiveDarkThemeSlotNo, - draftThemeSlots, - ) - !== serialiseThemeDraft ( - savedThemeMode, - savedActiveLightThemeSlotNo, - savedActiveDarkThemeSlotNo, - savedThemeSlots, - ), + serialiseThemeDraft (draftThemeMode, + draftActiveLightThemeSlotNo, + draftActiveDarkThemeSlotNo, + draftThemeSlots) + !== (serialiseThemeDraft (savedThemeMode, + savedActiveLightThemeSlotNo, + savedActiveDarkThemeSlotNo, + savedThemeSlots)), [draftActiveDarkThemeSlotNo, draftActiveLightThemeSlotNo, draftThemeMode, @@ -767,8 +756,10 @@ const SettingPage: FC = ({ user, setUser }) => { savedActiveLightThemeSlotNo, savedEditingThemeSlot, savedThemeMode, - savedThemeSlots], - ) + savedThemeSlots]) + const savedName = user?.name ?? '' + const hasUnsavedAccountChanges = name !== savedName + const hasPageUnsavedChanges = Object.values (dirtyStates).some (state => state.dirty) const applyActiveTab = (tab: SettingsTab) => { const params = new URLSearchParams (location.search) @@ -783,16 +774,16 @@ const SettingPage: FC = ({ user, setUser }) => { state: SettingsTabDirtyState, ) => { setDirtyStates (current => { - const next = { ...current } + const next = { ...current } - if (!(state.dirty)) - { - delete next[tab] - return next - } + if (!(state.dirty)) + { + delete next[tab] + return next + } - next[tab] = state - return next + next[tab] = state + return next }) } @@ -806,8 +797,7 @@ const SettingPage: FC = ({ user, setUser }) => { description, confirmText, cancelText, - variant: 'danger', - }) + variant: 'danger' }) const settingsTabErrors = useMemo> ( () => ( @@ -815,36 +805,35 @@ const SettingPage: FC = ({ user, setUser }) => { behavior: false, theme: Boolean (settingsFieldErrors.theme?.length), keyboard: conflictActionIds.size > 0 }), - [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name], - ) + [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name]) const applyDraftThemeState = ( { themeMode = draftThemeMode, activeLightThemeSlotNo = draftActiveLightThemeSlotNo, activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo, themeSlots = draftThemeSlots, - nextEditingThemeSlot = editingThemeSlot }: { - themeMode?: ClientThemeMode - activeLightThemeSlotNo?: UserThemeSlotNo - activeDarkThemeSlotNo?: UserThemeSlotNo - themeSlots?: UserThemeSlotMap - nextEditingThemeSlot?: UserThemeSlotSelection }, + nextEditingThemeSlot = editingThemeSlot, + }: { themeMode?: ClientThemeMode + activeLightThemeSlotNo?: UserThemeSlotNo + activeDarkThemeSlotNo?: UserThemeSlotNo + themeSlots?: UserThemeSlotMap + nextEditingThemeSlot?: UserThemeSlotSelection }, ) => { setDraftThemeMode (themeMode) setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo) setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo) setDraftThemeSlots (themeSlots) setEditingThemeSlot (nextEditingThemeSlot) - setDraftSettings (current => ({ - ...current, - theme: themeMode, - })) setCachedUserThemeSlots (themeSlots) applyThemeAppearanceState ({ themeMode, activeLightThemeSlotNo, - activeDarkThemeSlotNo, - }, themeSlots) + activeDarkThemeSlotNo }, themeSlots) + } + + const discardAccountChanges = () => { + setName (savedName) + clearUserValidationErrors () } const currentThemeDraftState = (): ThemeDraftState => ({ @@ -852,16 +841,14 @@ const SettingPage: FC = ({ user, setUser }) => { activeLightThemeSlotNo: draftActiveLightThemeSlotNo, activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, themeSlots: draftThemeSlots, - editingThemeSlot, - }) + editingThemeSlot }) const savedThemeDraftState = (): ThemeDraftState => ({ themeMode: savedThemeMode, activeLightThemeSlotNo: savedActiveLightThemeSlotNo, activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo, themeSlots: savedThemeSlots, - editingThemeSlot: savedEditingThemeSlot, - }) + editingThemeSlot: savedEditingThemeSlot }) const discardThemeChanges = () => { setDraftThemeMode (savedThemeMode) @@ -873,11 +860,19 @@ const SettingPage: FC = ({ user, setUser }) => { setClientThemeAppearance ({ themeMode: savedThemeMode, activeLightThemeSlotNo: savedActiveLightThemeSlotNo, - activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo, - }) + activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo }) applyClientAppearance (savedThemeSlots) } + const discardCurrentTabChanges = async (): Promise => { + const currentDirtyState = dirtyStates[activeTab] + + if (currentDirtyState?.dirty) + await currentDirtyState.discard () + + setDirtyStates ({ }) + } + const requestActiveTab = async ( nextTab: SettingsTab, ): Promise => { @@ -894,12 +889,11 @@ const SettingPage: FC = ({ user, setUser }) => { const confirmed = await confirmDiscardChanges ( 'このタブを離れると、保存していない変更は失われます。', 'このタブに残る', - '変更を破棄して移動', - ) + '変更を破棄して移動') if (!(confirmed)) return - await currentDirtyState.discard () + await discardCurrentTabChanges () applyActiveTab (nextTab) } @@ -919,8 +913,7 @@ const SettingPage: FC = ({ user, setUser }) => { const confirmed = await confirmDiscardChanges ( description, cancelText, - confirmText, - ) + confirmText) if (!(confirmed)) return } @@ -932,10 +925,52 @@ const SettingPage: FC = ({ user, setUser }) => { activeLightThemeSlotNo: nextState.activeLightThemeSlotNo, activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo, themeSlots: nextState.themeSlots, - nextEditingThemeSlot: nextState.editingThemeSlot, - }) + nextEditingThemeSlot: nextState.editingThemeSlot }) } + const routeBlocker = useBlocker (({ currentLocation, nextLocation }) => + hasPageUnsavedChanges + && currentLocation.pathname === SETTINGS_PATH + && nextLocation.pathname !== currentLocation.pathname) + + useEffect (() => { + if (routeBlocker.state !== 'blocked') + return + + let active = true + + void (async () => { + const confirmed = await confirmDiscardChanges ( + 'このまま移動すると、保存していない変更は失われます。', + 'このページに残る', + '変更を破棄して移動', + ) + if (!(active)) + return + + if (!(confirmed)) + { + routeBlocker.reset () + return + } + + await discardCurrentTabChanges () + routeBlocker.proceed () + }) () + + return () => { + active = false + } + }, [routeBlocker]) + + useBeforeUnload (event => { + if (!(hasPageUnsavedChanges)) + return + + event.preventDefault () + event.returnValue = '' + }, { capture: true }) + const updateDraftThemeSlot = ( selection: UserThemeSlotSelection, updater: (tokens: ThemeTokens) => ThemeTokens, @@ -945,16 +980,9 @@ const SettingPage: FC = ({ user, setUser }) => { ?? ensureCompleteThemeSlots ({ })[selection] const nextThemeSlots = { ...draftThemeSlots, - [selection]: { - ...currentThemeSlot, - tokens: updater (currentThemeSlot.tokens), - }, - } + [selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } } - applyDraftThemeState ({ - themeSlots: nextThemeSlots, - nextEditingThemeSlot: selection, - }) + applyDraftThemeState ({ themeSlots: nextThemeSlots, nextEditingThemeSlot: selection }) } const handleTabKeyDown = (event: KeyboardEvent) => { @@ -965,23 +993,23 @@ const SettingPage: FC = ({ user, setUser }) => { case 'ArrowDown': case 'ArrowRight': event.preventDefault () - void requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id) + requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id) break case 'ArrowUp': case 'ArrowLeft': event.preventDefault () - void requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id) + requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id) break case 'Home': event.preventDefault () - void requestActiveTab (tabs[0].id) + requestActiveTab (tabs[0].id) break case 'End': event.preventDefault () - void requestActiveTab (tabs[tabs.length - 1].id) + requestActiveTab (tabs[tabs.length - 1].id) break } } @@ -999,9 +1027,9 @@ const SettingPage: FC = ({ user, setUser }) => { { const data = await apiPut ( `/users/${ user.id }`, formData, - { headers: { 'Content-Type': 'multipart/form-data' } }, - ) + { headers: { 'Content-Type': 'multipart/form-data' } }) setUser (currentUser => ({ ...currentUser, ...data })) + setName (data.name ?? '') toast ({ title: '表示名を更新しました.' }) } catch (submitError) @@ -1026,11 +1054,9 @@ const SettingPage: FC = ({ user, setUser }) => { selection === editingThemeSlot && savedThemeSlot?.createdAt == null - if ( - JSON.stringify (draftThemeSlot?.tokens) - === JSON.stringify (savedThemeSlot?.tokens) - && !(requiresPersist) - ) + if ((JSON.stringify (draftThemeSlot?.tokens) + === JSON.stringify (savedThemeSlot?.tokens)) + && !(requiresPersist)) continue const persisted = await upsertUserThemeSlot ( @@ -1038,19 +1064,12 @@ const SettingPage: FC = ({ user, setUser }) => { 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, - } + Number (selection.split (':')[2]) as UserThemeSlotNo).tokens) + nextSavedThemeSlots = { ...nextSavedThemeSlots, [selection]: persisted } } - const data = await updateUserSettings ({ theme: draftThemeMode }) const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots) - setDraftSettings ({ ...data, theme: draftThemeMode }) setSavedThemeMode (draftThemeMode) setSavedActiveLightThemeSlotNo (draftActiveLightThemeSlotNo) setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo) @@ -1058,11 +1077,11 @@ const SettingPage: FC = ({ user, setUser }) => { setDraftThemeSlots (completeThemeSlots) setSavedEditingThemeSlot (editingThemeSlot) setEditingThemeSlot (editingThemeSlot) + // Display mode and active slot numbers are browser-local state. setClientThemeAppearance ({ themeMode: draftThemeMode, activeLightThemeSlotNo: draftActiveLightThemeSlotNo, - activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, - }) + activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }) setCachedUserThemeSlots (completeThemeSlots) applyClientAppearance (completeThemeSlots) toast ({ title: 'テーマ設定を保存しました.' }) @@ -1096,10 +1115,8 @@ const SettingPage: FC = ({ user, setUser }) => { try { - const [settings, themeSlots] = await Promise.all ([ - fetchUserSettings (), - fetchUserThemeSlots (), - ]) + const [settings, themeSlots] = await Promise.all ([fetchUserSettings (), + fetchUserThemeSlots ()]) if (cancelled) return @@ -1114,10 +1131,8 @@ const SettingPage: FC = ({ user, setUser }) => { const initialEditingThemeSlot = resolveDisplayedTheme ({ themeMode, activeLightThemeSlotNo, - activeDarkThemeSlotNo, - }, completeThemeSlots).selection + activeDarkThemeSlotNo }, completeThemeSlots).selection - setDraftSettings ({ ...settings, theme: themeMode }) setSavedThemeMode (themeMode) setDraftThemeMode (themeMode) setSavedActiveLightThemeSlotNo (activeLightThemeSlotNo) @@ -1129,11 +1144,9 @@ const SettingPage: FC = ({ user, setUser }) => { setSavedEditingThemeSlot (initialEditingThemeSlot) setEditingThemeSlot (initialEditingThemeSlot) setCachedUserThemeSlots (completeThemeSlots) - applyThemeAppearanceState ({ - themeMode, - activeLightThemeSlotNo, - activeDarkThemeSlotNo, - }, completeThemeSlots) + applyThemeAppearanceState ({ themeMode, + activeLightThemeSlotNo, + activeDarkThemeSlotNo }, completeThemeSlots) setSettingsError (null) } catch @@ -1160,6 +1173,12 @@ const SettingPage: FC = ({ user, setUser }) => { applyActiveTab ('account') }, [location.pathname, location.search, userFieldErrors.name]) + useEffect (() => { + updateTabDirtyState ('account', { + dirty: hasUnsavedAccountChanges, + discard: discardAccountChanges }) + }, [hasUnsavedAccountChanges, savedName]) + useEffect (() => { if (settingsFieldErrors.theme?.length) applyActiveTab ('theme') @@ -1169,8 +1188,7 @@ const SettingPage: FC = ({ user, setUser }) => { applyThemeAppearanceState ({ themeMode: draftThemeMode, activeLightThemeSlotNo: draftActiveLightThemeSlotNo, - activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, - }, draftThemeSlots) + activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots) return () => { setCachedUserThemeSlots (savedThemeSlots) @@ -1184,39 +1202,35 @@ const SettingPage: FC = ({ user, setUser }) => { const accountSectionProps: AccountSectionProps | null = user - ? { - user, + ? { user, name, onNameChange: setName, onInheritOpen: () => setInheritVsbl (true), onUserCodeOpen: () => setUserCodeVsbl (true), onUserSubmit: handleUserSubmit, userBaseErrors, - userFieldErrors, - } + userFieldErrors } : null useEffect (() => { updateTabDirtyState ('theme', { dirty: hasUnsavedThemeChanges, - discard: handleThemeDiscard, - }) + discard: handleThemeDiscard }) }, [hasUnsavedThemeChanges, savedThemeSlots]) useKeyboardShortcuts ({ - 'settings.account': () => { - void requestActiveTab ('account') + ['settings.account']: () => { + requestActiveTab ('account') }, - 'settings.behavior': () => { - void requestActiveTab ('behavior') + ['settings.behavior']: () => { + requestActiveTab ('behavior') }, - 'settings.theme': () => { - void requestActiveTab ('theme') + ['settings.theme']: () => { + requestActiveTab ('theme') }, - 'settings.keyboard': () => { - void requestActiveTab ('keyboard') - }, - }) + ['settings.keyboard']: () => { + requestActiveTab ('keyboard') + } }) const themeSectionProps: ThemeSectionProps = { draftThemeMode, @@ -1230,62 +1244,43 @@ const SettingPage: FC = ({ user, setUser }) => { settingsBaseErrors, settingsFieldErrors, onSelectThemeMode: themeMode => { - void requestThemeStateChange (baseState => ({ - ...baseState, - themeMode, - })) + requestThemeStateChange (baseState => ({ ...baseState, themeMode })) }, onSelectLightThemeSlotNo: slotNo => { - void requestThemeStateChange (baseState => ({ - ...baseState, - activeLightThemeSlotNo: slotNo, - editingThemeSlot: getUserThemeSlotSelection ('light', slotNo), - })) + requestThemeStateChange (baseState => ({ + ...baseState, + activeLightThemeSlotNo: slotNo, + editingThemeSlot: getUserThemeSlotSelection ('light', slotNo) })) }, onSelectDarkThemeSlotNo: slotNo => { - void requestThemeStateChange (baseState => ({ - ...baseState, - activeDarkThemeSlotNo: slotNo, - editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo), - })) + requestThemeStateChange (baseState => ({ + ...baseState, + activeDarkThemeSlotNo: slotNo, + editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo) })) }, onSelectEditingThemeSlot: selection => { - void requestThemeStateChange (baseState => ({ - ...baseState, - editingThemeSlot: selection, - })) + requestThemeStateChange (baseState => ({ ...baseState, editingThemeSlot: selection })) }, onThemeTokenChange: (key, value) => { - updateDraftThemeSlot (editingThemeSlot, tokens => ({ - ...tokens, - [key]: value, - })) + updateDraftThemeSlot (editingThemeSlot, tokens => ({ ...tokens, [key]: value })) }, onTagColourChange: (category, colour) => { - updateDraftThemeSlot (editingThemeSlot, tokens => ({ - ...tokens, - tagColours: { - ...tokens.tagColours, - [category]: colour, - }, - })) + updateDraftThemeSlot (editingThemeSlot, tokens => ({ + ...tokens, tagColours: { ...tokens.tagColours, [category]: colour } })) }, onResetThemeSlot: selection => { - const baseTheme = - draftThemeSlots[selection]?.baseTheme - ?? selection.split (':')[1] as BuiltinThemeId - updateDraftThemeSlot ( - selection, - () => getDefaultUserThemeSlot ( - baseTheme, - draftThemeSlots[selection]?.slotNo - ?? Number (selection.split (':')[2]) as UserThemeSlotNo, - ).tokens, - ) + 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, - } + onThemeDiscard: handleThemeDiscard } return (