このコミットが含まれているのは:
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
`;([...]).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 = <Key extends keyof Settings,> (
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 `</div>)`.
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
生成ファイル
-6
ファイルの表示
@@ -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
-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
{
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'] *
{
+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 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 => {
+173 -178
ファイルの表示
@@ -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<Props> = ({ user, setUser }) => {
const [loading, setLoading] = useState (true)
const [name, setName] = useState ('')
const [settingsError, setSettingsError] = useState<string | null> (null)
const [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
const [savedThemeMode, setSavedThemeMode] =
useState<ClientThemeMode> (getClientThemeMode ())
const [draftThemeMode, setDraftThemeMode] =
@@ -719,45 +716,37 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
() => resolveDisplayedTheme ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
}, draftThemeSlots),
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
[draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots],
)
draftThemeSlots])
const selectedTheme = useMemo<ClientTheme> (
() => {
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<Props> = ({ 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<Props> = ({ 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<Props> = ({ user, setUser }) => {
description,
confirmText,
cancelText,
variant: 'danger',
})
variant: 'danger' })
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
() => (
@@ -815,36 +805,35 @@ const SettingPage: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ user, setUser }) => {
setClientThemeAppearance ({
themeMode: savedThemeMode,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
})
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo })
applyClientAppearance (savedThemeSlots)
}
const discardCurrentTabChanges = async (): Promise<void> => {
const currentDirtyState = dirtyStates[activeTab]
if (currentDirtyState?.dirty)
await currentDirtyState.discard ()
setDirtyStates ({ })
}
const requestActiveTab = async (
nextTab: SettingsTab,
): Promise<void> => {
@@ -894,12 +889,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const confirmed = await confirmDiscardChanges (
'このタブを離れると、保存していない変更は失われます。',
'このタブに残る',
'変更を破棄して移動',
)
'変更を破棄して移動')
if (!(confirmed))
return
await currentDirtyState.discard ()
await discardCurrentTabChanges ()
applyActiveTab (nextTab)
}
@@ -919,8 +913,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const confirmed = await confirmDiscardChanges (
description,
cancelText,
confirmText,
)
confirmText)
if (!(confirmed))
return
}
@@ -932,10 +925,52 @@ const SettingPage: FC<Props> = ({ 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<Props> = ({ 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<HTMLButtonElement>) => {
@@ -965,23 +993,23 @@ const SettingPage: FC<Props> = ({ 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<Props> = ({ user, setUser }) => {
{
const data = await apiPut<User> (
`/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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ user, setUser }) => {
applyThemeAppearanceState ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
}, draftThemeSlots)
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots)
return () => {
setCachedUserThemeSlots (savedThemeSlots)
@@ -1184,39 +1202,35 @@ const SettingPage: FC<Props> = ({ 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<Props> = ({ 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 (
<MainArea>