このコミットが含まれているのは:
2026-07-06 19:34:11 +09:00
コミット f4dea9b91e
6個のファイルの変更290行の追加305行の削除
+111
ファイルの表示
@@ -362,6 +362,20 @@ const value =
- Do not use a leading semicolon for expression statements such as - Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`. 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, - Use correct British English spelling for new identifiers, filenames,
component names, helper names, comments, and developer-facing prose unless component names, helper names, comments, and developer-facing prose unless
editing an already established American-English API that must keep its 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 - For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not
`BehaviorSettingsSection.tsx`. `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 ### Frontend delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is 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 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 `}` at the beginning of a line. Prefer keeping `{` with the first binding and `}`
with the final binding when this fits the line limit. with the final binding when this fits the line limit.
Penalty: capital offence. This is one of the core associative-brace rules.
#### Type literals #### Type literals
@@ -434,6 +525,8 @@ type Props = {
Rule: type-literal `}` is not a block close. It must stay on the same line as 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. 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 #### Object literals
@@ -474,6 +567,8 @@ const value = useMemo (() => ({
Rule: object-literal `}` is associative-array-style syntax. It must not be on a 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 line by itself. Keep it with the final property, and keep call `)` off the
beginning of a line. beginning of a line.
Penalty: capital offence. Wrong object-literal `}` placement and wrong call
`)` placement are both death-class violations here.
#### Destructuring parameters #### 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 `}` shape. Put the function parameter list on its own lines. The destructuring `}`
stays with the final binding. The parameter-list `)` is then allowed and stays with the final binding. The parameter-list `)` is then allowed and
required at the beginning of its own line. 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 #### 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 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 inside the inline type. Keep the type-literal `}` and parameter-list `)` on the
same line as the final type property. 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 #### Multi-line normal parameter list
@@ -558,6 +657,8 @@ const updateDraft = <Key extends keyof Settings,> (
Rule: when the parameter list itself is split across multiple parameter lines, 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 closing parameter `)` goes at the beginning of its own line before `=>` or
the return type. 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 #### Function and callback blocks
@@ -586,6 +687,8 @@ const handleSave = () => {
Rule: block `}` closes executable code, not associative data. Multi-line Rule: block `}` closes executable code, not associative data. Multi-line
function, lambda, callback, `if`, `for`, `switch`, and similar block braces function, lambda, callback, `if`, `for`, `switch`, and similar block braces
belong on their own line. belong on their own line.
Penalty: capital offence. Confusing block `}` with associative `}` is a
death-class delimiter error.
#### Function and method calls #### 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 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 for leading `)` applies only to function declaration parameter lists, never to
calls. calls.
Penalty: capital offence. A leading call `)` is one of the clearest death-class
violations in this codebase.
#### JSX closing markers #### JSX closing markers
@@ -653,6 +758,8 @@ Good:
Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in
the local compact form such as `</div>)`. the local compact form such as `</div>)`.
Penalty: medium-severity offence. It is usually not semantically broken, but it
is a conspicuous TSX-style failure.
#### Arrays and tuples #### 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 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. 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 #### Single-line braces
@@ -705,6 +814,8 @@ Good:
Rule: JavaScript object braces on one line get one inner space. JSX expression Rule: JavaScript object braces on one line get one inner space. JSX expression
braces do not get inner spaces. 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 #### Final TypeScript/TSX self-review checklist
生成ファイル
-6
ファイルの表示
@@ -371,12 +371,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "theme", default: "system", 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_title", default: "manual", null: false
t.string "auto_fetch_thumbnail", default: "manual", null: false t.string "auto_fetch_thumbnail", default: "manual", null: false
t.string "wiki_editor_mode", default: "split", null: false t.string "wiki_editor_mode", default: "split", null: false
-97
ファイルの表示
@@ -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<SetStateAction<UserSettings>> }
const UserSettingsContext = createContext<ContextValue | null> (null)
export const UserSettingsProvider: FC<{
children: ReactNode
user: User | null }> = ({ children, user }) => {
const [error, setError] = useState<string | null> (null)
const [loaded, setLoaded] = useState (false)
const [settings, setSettings] = useState<UserSettings> (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<ContextValue> (() => ({
error,
loaded,
settings,
setSettings,
}), [error, loaded, settings])
return (
<UserSettingsContext.Provider value={value}>
{children}
</UserSettingsContext.Provider>)
}
export const useUserSettings = (): ContextValue => {
const value = useContext (UserSettingsContext)
if (value == null)
throw new Error ('UserSettingsProvider is missing')
return value
}
+1 -24
ファイルの表示
@@ -105,7 +105,7 @@
} }
a:hover a:hover
{ {
opacity: .85; color: color-mix(in oklab, hsl(var(--theme-link)), white 18%);
} }
} }
@@ -125,29 +125,6 @@
-moz-osx-font-smoothing: grayscale; -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='reduced'] *,
:root[data-animation='off'] * :root[data-animation='off'] *
{ {
+5
ファイルの表示
@@ -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 legacyThemeSlotSelection = (): ThemeSlotSelection => {
const appearance = appearanceFromSettings () const appearance = appearanceFromSettings ()
const customThemes = getClientCustomThemes () const customThemes = getClientCustomThemes ()
@@ -774,6 +778,7 @@ export const getClientActiveThemeSlot = (): ThemeSlotSelection =>
?? legacyThemeSlotSelection () ?? legacyThemeSlotSelection ()
// Legacy write path. New code should use `setClientThemeAppearance`.
export const setClientActiveThemeSlot = ( export const setClientActiveThemeSlot = (
activeThemeSlot: ThemeSlotSelection, activeThemeSlot: ThemeSlotSelection,
): void => { ): void => {
+133 -138
ファイルの表示
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async' 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 FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
@@ -21,7 +21,6 @@ import { apiPut } from '@/lib/api'
import { import {
applyClientAppearance, applyClientAppearance,
applyThemeAppearanceState, applyThemeAppearanceState,
DEFAULT_USER_SETTINGS,
fetchUserSettings, fetchUserSettings,
fetchUserThemeSlots, fetchUserThemeSlots,
getClientActiveDarkThemeSlotNo, getClientActiveDarkThemeSlotNo,
@@ -34,7 +33,6 @@ import {
resolveDisplayedTheme, resolveDisplayedTheme,
setCachedUserThemeSlots, setCachedUserThemeSlots,
setClientThemeAppearance, setClientThemeAppearance,
updateUserSettings,
upsertUserThemeSlot, upsertUserThemeSlot,
USER_THEME_SLOT_SELECTIONS, USER_THEME_SLOT_SELECTIONS,
} from '@/lib/settings' } from '@/lib/settings'
@@ -51,7 +49,6 @@ import type {
ClientTheme, ClientTheme,
ThemeTokens, ThemeTokens,
ThemeTagColours, ThemeTagColours,
UserSettings,
UserThemeSlotMap, UserThemeSlotMap,
UserThemeSlotNo, UserThemeSlotNo,
UserThemeSlotSelection, UserThemeSlotSelection,
@@ -224,6 +221,7 @@ const parseTab = (search: string): SettingsTab => {
const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }` const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }`
const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }` const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }`
const SETTINGS_PATH = '/users/settings'
const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties => const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
Object.fromEntries ( Object.fromEntries (
@@ -679,7 +677,6 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const [loading, setLoading] = useState (true) const [loading, setLoading] = useState (true)
const [name, setName] = useState ('') const [name, setName] = useState ('')
const [settingsError, setSettingsError] = useState<string | null> (null) const [settingsError, setSettingsError] = useState<string | null> (null)
const [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
const [savedThemeMode, setSavedThemeMode] = const [savedThemeMode, setSavedThemeMode] =
useState<ClientThemeMode> (getClientThemeMode ()) useState<ClientThemeMode> (getClientThemeMode ())
const [draftThemeMode, setDraftThemeMode] = const [draftThemeMode, setDraftThemeMode] =
@@ -719,13 +716,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
() => resolveDisplayedTheme ({ () => resolveDisplayedTheme ({
themeMode: draftThemeMode, themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo, activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
}, draftThemeSlots),
[draftActiveDarkThemeSlotNo, [draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo, draftActiveLightThemeSlotNo,
draftThemeMode, draftThemeMode,
draftThemeSlots], draftThemeSlots])
)
const selectedTheme = useMemo<ClientTheme> ( const selectedTheme = useMemo<ClientTheme> (
() => { () => {
const currentSelection = const currentSelection =
@@ -739,25 +734,19 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
name: themeLabelBySelection (currentSelection.id), name: themeLabelBySelection (currentSelection.id),
builtin: false, builtin: false,
baseTheme: currentSelection.baseTheme, baseTheme: currentSelection.baseTheme,
tokens: themeSlot.tokens, tokens: themeSlot.tokens }
}
}, },
[draftThemeSlots, editingThemeSlot], [draftThemeSlots, editingThemeSlot])
)
const hasUnsavedThemeChanges = useMemo ( const hasUnsavedThemeChanges = useMemo (
() => () =>
serialiseThemeDraft ( serialiseThemeDraft (draftThemeMode,
draftThemeMode,
draftActiveLightThemeSlotNo, draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo, draftActiveDarkThemeSlotNo,
draftThemeSlots, draftThemeSlots)
) !== (serialiseThemeDraft (savedThemeMode,
!== serialiseThemeDraft (
savedThemeMode,
savedActiveLightThemeSlotNo, savedActiveLightThemeSlotNo,
savedActiveDarkThemeSlotNo, savedActiveDarkThemeSlotNo,
savedThemeSlots, savedThemeSlots)),
),
[draftActiveDarkThemeSlotNo, [draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo, draftActiveLightThemeSlotNo,
draftThemeMode, draftThemeMode,
@@ -767,8 +756,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
savedActiveLightThemeSlotNo, savedActiveLightThemeSlotNo,
savedEditingThemeSlot, savedEditingThemeSlot,
savedThemeMode, 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 applyActiveTab = (tab: SettingsTab) => {
const params = new URLSearchParams (location.search) const params = new URLSearchParams (location.search)
@@ -806,8 +797,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
description, description,
confirmText, confirmText,
cancelText, cancelText,
variant: 'danger', variant: 'danger' })
})
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> ( const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
() => ( () => (
@@ -815,16 +805,15 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
behavior: false, behavior: false,
theme: Boolean (settingsFieldErrors.theme?.length), theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: conflictActionIds.size > 0 }), keyboard: conflictActionIds.size > 0 }),
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name], [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name])
)
const applyDraftThemeState = ( const applyDraftThemeState = (
{ themeMode = draftThemeMode, { themeMode = draftThemeMode,
activeLightThemeSlotNo = draftActiveLightThemeSlotNo, activeLightThemeSlotNo = draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo, activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo,
themeSlots = draftThemeSlots, themeSlots = draftThemeSlots,
nextEditingThemeSlot = editingThemeSlot }: { nextEditingThemeSlot = editingThemeSlot,
themeMode?: ClientThemeMode }: { themeMode?: ClientThemeMode
activeLightThemeSlotNo?: UserThemeSlotNo activeLightThemeSlotNo?: UserThemeSlotNo
activeDarkThemeSlotNo?: UserThemeSlotNo activeDarkThemeSlotNo?: UserThemeSlotNo
themeSlots?: UserThemeSlotMap themeSlots?: UserThemeSlotMap
@@ -835,16 +824,16 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo) setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
setDraftThemeSlots (themeSlots) setDraftThemeSlots (themeSlots)
setEditingThemeSlot (nextEditingThemeSlot) setEditingThemeSlot (nextEditingThemeSlot)
setDraftSettings (current => ({
...current,
theme: themeMode,
}))
setCachedUserThemeSlots (themeSlots) setCachedUserThemeSlots (themeSlots)
applyThemeAppearanceState ({ applyThemeAppearanceState ({
themeMode, themeMode,
activeLightThemeSlotNo, activeLightThemeSlotNo,
activeDarkThemeSlotNo, activeDarkThemeSlotNo }, themeSlots)
}, themeSlots) }
const discardAccountChanges = () => {
setName (savedName)
clearUserValidationErrors ()
} }
const currentThemeDraftState = (): ThemeDraftState => ({ const currentThemeDraftState = (): ThemeDraftState => ({
@@ -852,16 +841,14 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
activeLightThemeSlotNo: draftActiveLightThemeSlotNo, activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
themeSlots: draftThemeSlots, themeSlots: draftThemeSlots,
editingThemeSlot, editingThemeSlot })
})
const savedThemeDraftState = (): ThemeDraftState => ({ const savedThemeDraftState = (): ThemeDraftState => ({
themeMode: savedThemeMode, themeMode: savedThemeMode,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo, activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo, activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
themeSlots: savedThemeSlots, themeSlots: savedThemeSlots,
editingThemeSlot: savedEditingThemeSlot, editingThemeSlot: savedEditingThemeSlot })
})
const discardThemeChanges = () => { const discardThemeChanges = () => {
setDraftThemeMode (savedThemeMode) setDraftThemeMode (savedThemeMode)
@@ -873,11 +860,19 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setClientThemeAppearance ({ setClientThemeAppearance ({
themeMode: savedThemeMode, themeMode: savedThemeMode,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo, activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo, activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo })
})
applyClientAppearance (savedThemeSlots) applyClientAppearance (savedThemeSlots)
} }
const discardCurrentTabChanges = async (): Promise<void> => {
const currentDirtyState = dirtyStates[activeTab]
if (currentDirtyState?.dirty)
await currentDirtyState.discard ()
setDirtyStates ({ })
}
const requestActiveTab = async ( const requestActiveTab = async (
nextTab: SettingsTab, nextTab: SettingsTab,
): Promise<void> => { ): Promise<void> => {
@@ -894,12 +889,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const confirmed = await confirmDiscardChanges ( const confirmed = await confirmDiscardChanges (
'このタブを離れると、保存していない変更は失われます。', 'このタブを離れると、保存していない変更は失われます。',
'このタブに残る', 'このタブに残る',
'変更を破棄して移動', '変更を破棄して移動')
)
if (!(confirmed)) if (!(confirmed))
return return
await currentDirtyState.discard () await discardCurrentTabChanges ()
applyActiveTab (nextTab) applyActiveTab (nextTab)
} }
@@ -919,8 +913,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const confirmed = await confirmDiscardChanges ( const confirmed = await confirmDiscardChanges (
description, description,
cancelText, cancelText,
confirmText, confirmText)
)
if (!(confirmed)) if (!(confirmed))
return return
} }
@@ -932,10 +925,52 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo, activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo, activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
themeSlots: nextState.themeSlots, 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 = ( const updateDraftThemeSlot = (
selection: UserThemeSlotSelection, selection: UserThemeSlotSelection,
updater: (tokens: ThemeTokens) => ThemeTokens, updater: (tokens: ThemeTokens) => ThemeTokens,
@@ -945,16 +980,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
?? ensureCompleteThemeSlots ({ })[selection] ?? ensureCompleteThemeSlots ({ })[selection]
const nextThemeSlots = { const nextThemeSlots = {
...draftThemeSlots, ...draftThemeSlots,
[selection]: { [selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } }
...currentThemeSlot,
tokens: updater (currentThemeSlot.tokens),
},
}
applyDraftThemeState ({ applyDraftThemeState ({ themeSlots: nextThemeSlots, nextEditingThemeSlot: selection })
themeSlots: nextThemeSlots,
nextEditingThemeSlot: selection,
})
} }
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => { const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
@@ -965,23 +993,23 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
case 'ArrowDown': case 'ArrowDown':
case 'ArrowRight': case 'ArrowRight':
event.preventDefault () event.preventDefault ()
void requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id) requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
break break
case 'ArrowUp': case 'ArrowUp':
case 'ArrowLeft': case 'ArrowLeft':
event.preventDefault () event.preventDefault ()
void requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id) requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
break break
case 'Home': case 'Home':
event.preventDefault () event.preventDefault ()
void requestActiveTab (tabs[0].id) requestActiveTab (tabs[0].id)
break break
case 'End': case 'End':
event.preventDefault () event.preventDefault ()
void requestActiveTab (tabs[tabs.length - 1].id) requestActiveTab (tabs[tabs.length - 1].id)
break break
} }
} }
@@ -999,9 +1027,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{ {
const data = await apiPut<User> ( const data = await apiPut<User> (
`/users/${ user.id }`, formData, `/users/${ user.id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }, { headers: { 'Content-Type': 'multipart/form-data' } })
)
setUser (currentUser => ({ ...currentUser, ...data })) setUser (currentUser => ({ ...currentUser, ...data }))
setName (data.name ?? '')
toast ({ title: '表示名を更新しました.' }) toast ({ title: '表示名を更新しました.' })
} }
catch (submitError) catch (submitError)
@@ -1026,11 +1054,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
selection === editingThemeSlot selection === editingThemeSlot
&& savedThemeSlot?.createdAt == null && savedThemeSlot?.createdAt == null
if ( if ((JSON.stringify (draftThemeSlot?.tokens)
JSON.stringify (draftThemeSlot?.tokens) === JSON.stringify (savedThemeSlot?.tokens))
=== JSON.stringify (savedThemeSlot?.tokens) && !(requiresPersist))
&& !(requiresPersist)
)
continue continue
const persisted = await upsertUserThemeSlot ( const persisted = await upsertUserThemeSlot (
@@ -1038,19 +1064,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo, draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo,
draftThemeSlot?.tokens ?? getDefaultUserThemeSlot ( draftThemeSlot?.tokens ?? getDefaultUserThemeSlot (
selection.split (':')[1] as BuiltinThemeId, selection.split (':')[1] as BuiltinThemeId,
Number (selection.split (':')[2]) as UserThemeSlotNo, Number (selection.split (':')[2]) as UserThemeSlotNo).tokens)
).tokens, nextSavedThemeSlots = { ...nextSavedThemeSlots, [selection]: persisted }
)
nextSavedThemeSlots = {
...nextSavedThemeSlots,
[selection]: persisted,
}
} }
const data = await updateUserSettings ({ theme: draftThemeMode })
const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots) const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)
setDraftSettings ({ ...data, theme: draftThemeMode })
setSavedThemeMode (draftThemeMode) setSavedThemeMode (draftThemeMode)
setSavedActiveLightThemeSlotNo (draftActiveLightThemeSlotNo) setSavedActiveLightThemeSlotNo (draftActiveLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo) setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
@@ -1058,11 +1077,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setDraftThemeSlots (completeThemeSlots) setDraftThemeSlots (completeThemeSlots)
setSavedEditingThemeSlot (editingThemeSlot) setSavedEditingThemeSlot (editingThemeSlot)
setEditingThemeSlot (editingThemeSlot) setEditingThemeSlot (editingThemeSlot)
// Display mode and active slot numbers are browser-local state.
setClientThemeAppearance ({ setClientThemeAppearance ({
themeMode: draftThemeMode, themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo, activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo })
})
setCachedUserThemeSlots (completeThemeSlots) setCachedUserThemeSlots (completeThemeSlots)
applyClientAppearance (completeThemeSlots) applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' }) toast ({ title: 'テーマ設定を保存しました.' })
@@ -1096,10 +1115,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
try try
{ {
const [settings, themeSlots] = await Promise.all ([ const [settings, themeSlots] = await Promise.all ([fetchUserSettings (),
fetchUserSettings (), fetchUserThemeSlots ()])
fetchUserThemeSlots (),
])
if (cancelled) if (cancelled)
return return
@@ -1114,10 +1131,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const initialEditingThemeSlot = resolveDisplayedTheme ({ const initialEditingThemeSlot = resolveDisplayedTheme ({
themeMode, themeMode,
activeLightThemeSlotNo, activeLightThemeSlotNo,
activeDarkThemeSlotNo, activeDarkThemeSlotNo }, completeThemeSlots).selection
}, completeThemeSlots).selection
setDraftSettings ({ ...settings, theme: themeMode })
setSavedThemeMode (themeMode) setSavedThemeMode (themeMode)
setDraftThemeMode (themeMode) setDraftThemeMode (themeMode)
setSavedActiveLightThemeSlotNo (activeLightThemeSlotNo) setSavedActiveLightThemeSlotNo (activeLightThemeSlotNo)
@@ -1129,11 +1144,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setSavedEditingThemeSlot (initialEditingThemeSlot) setSavedEditingThemeSlot (initialEditingThemeSlot)
setEditingThemeSlot (initialEditingThemeSlot) setEditingThemeSlot (initialEditingThemeSlot)
setCachedUserThemeSlots (completeThemeSlots) setCachedUserThemeSlots (completeThemeSlots)
applyThemeAppearanceState ({ applyThemeAppearanceState ({ themeMode,
themeMode,
activeLightThemeSlotNo, activeLightThemeSlotNo,
activeDarkThemeSlotNo, activeDarkThemeSlotNo }, completeThemeSlots)
}, completeThemeSlots)
setSettingsError (null) setSettingsError (null)
} }
catch catch
@@ -1160,6 +1173,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
applyActiveTab ('account') applyActiveTab ('account')
}, [location.pathname, location.search, userFieldErrors.name]) }, [location.pathname, location.search, userFieldErrors.name])
useEffect (() => {
updateTabDirtyState ('account', {
dirty: hasUnsavedAccountChanges,
discard: discardAccountChanges })
}, [hasUnsavedAccountChanges, savedName])
useEffect (() => { useEffect (() => {
if (settingsFieldErrors.theme?.length) if (settingsFieldErrors.theme?.length)
applyActiveTab ('theme') applyActiveTab ('theme')
@@ -1169,8 +1188,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
applyThemeAppearanceState ({ applyThemeAppearanceState ({
themeMode: draftThemeMode, themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo, activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots)
}, draftThemeSlots)
return () => { return () => {
setCachedUserThemeSlots (savedThemeSlots) setCachedUserThemeSlots (savedThemeSlots)
@@ -1184,39 +1202,35 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const accountSectionProps: AccountSectionProps | null = const accountSectionProps: AccountSectionProps | null =
user user
? { ? { user,
user,
name, name,
onNameChange: setName, onNameChange: setName,
onInheritOpen: () => setInheritVsbl (true), onInheritOpen: () => setInheritVsbl (true),
onUserCodeOpen: () => setUserCodeVsbl (true), onUserCodeOpen: () => setUserCodeVsbl (true),
onUserSubmit: handleUserSubmit, onUserSubmit: handleUserSubmit,
userBaseErrors, userBaseErrors,
userFieldErrors, userFieldErrors }
}
: null : null
useEffect (() => { useEffect (() => {
updateTabDirtyState ('theme', { updateTabDirtyState ('theme', {
dirty: hasUnsavedThemeChanges, dirty: hasUnsavedThemeChanges,
discard: handleThemeDiscard, discard: handleThemeDiscard })
})
}, [hasUnsavedThemeChanges, savedThemeSlots]) }, [hasUnsavedThemeChanges, savedThemeSlots])
useKeyboardShortcuts ({ useKeyboardShortcuts ({
'settings.account': () => { ['settings.account']: () => {
void requestActiveTab ('account') requestActiveTab ('account')
}, },
'settings.behavior': () => { ['settings.behavior']: () => {
void requestActiveTab ('behavior') requestActiveTab ('behavior')
}, },
'settings.theme': () => { ['settings.theme']: () => {
void requestActiveTab ('theme') requestActiveTab ('theme')
}, },
'settings.keyboard': () => { ['settings.keyboard']: () => {
void requestActiveTab ('keyboard') requestActiveTab ('keyboard')
}, } })
})
const themeSectionProps: ThemeSectionProps = { const themeSectionProps: ThemeSectionProps = {
draftThemeMode, draftThemeMode,
@@ -1230,45 +1244,29 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
settingsBaseErrors, settingsBaseErrors,
settingsFieldErrors, settingsFieldErrors,
onSelectThemeMode: themeMode => { onSelectThemeMode: themeMode => {
void requestThemeStateChange (baseState => ({ requestThemeStateChange (baseState => ({ ...baseState, themeMode }))
...baseState,
themeMode,
}))
}, },
onSelectLightThemeSlotNo: slotNo => { onSelectLightThemeSlotNo: slotNo => {
void requestThemeStateChange (baseState => ({ requestThemeStateChange (baseState => ({
...baseState, ...baseState,
activeLightThemeSlotNo: slotNo, activeLightThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('light', slotNo), editingThemeSlot: getUserThemeSlotSelection ('light', slotNo) }))
}))
}, },
onSelectDarkThemeSlotNo: slotNo => { onSelectDarkThemeSlotNo: slotNo => {
void requestThemeStateChange (baseState => ({ requestThemeStateChange (baseState => ({
...baseState, ...baseState,
activeDarkThemeSlotNo: slotNo, activeDarkThemeSlotNo: slotNo,
editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo), editingThemeSlot: getUserThemeSlotSelection ('dark', slotNo) }))
}))
}, },
onSelectEditingThemeSlot: selection => { onSelectEditingThemeSlot: selection => {
void requestThemeStateChange (baseState => ({ requestThemeStateChange (baseState => ({ ...baseState, editingThemeSlot: selection }))
...baseState,
editingThemeSlot: selection,
}))
}, },
onThemeTokenChange: (key, value) => { onThemeTokenChange: (key, value) => {
updateDraftThemeSlot (editingThemeSlot, tokens => ({ updateDraftThemeSlot (editingThemeSlot, tokens => ({ ...tokens, [key]: value }))
...tokens,
[key]: value,
}))
}, },
onTagColourChange: (category, colour) => { onTagColourChange: (category, colour) => {
updateDraftThemeSlot (editingThemeSlot, tokens => ({ updateDraftThemeSlot (editingThemeSlot, tokens => ({
...tokens, ...tokens, tagColours: { ...tokens.tagColours, [category]: colour } }))
tagColours: {
...tokens.tagColours,
[category]: colour,
},
}))
}, },
onResetThemeSlot: selection => { onResetThemeSlot: selection => {
const baseTheme = const baseTheme =
@@ -1278,14 +1276,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
selection, selection,
() => getDefaultUserThemeSlot ( () => getDefaultUserThemeSlot (
baseTheme, baseTheme,
draftThemeSlots[selection]?.slotNo (draftThemeSlots[selection]?.slotNo
?? Number (selection.split (':')[2]) as UserThemeSlotNo, ?? Number (selection.split (':')[2]) as UserThemeSlotNo)).tokens)
).tokens,
)
}, },
onThemeSubmit: handleThemeSubmit, onThemeSubmit: handleThemeSubmit,
onThemeDiscard: handleThemeDiscard, onThemeDiscard: handleThemeDiscard }
}
return ( return (
<MainArea> <MainArea>