このコミットが含まれているのは:
@@ -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
|
||||
|
||||
@@ -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'] *
|
||||
{
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
新しい課題から参照
ユーザをブロックする