コミットを比較

...

5 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 8ac56f74ce #34 2026-07-06 22:02:25 +09:00
みてるぞ 59a87c0e11 #34 2026-07-06 21:17:39 +09:00
みてるぞ f66532a55d #34 2026-07-06 21:12:49 +09:00
みてるぞ c3d2d8e1d1 #34 2026-07-06 20:46:16 +09:00
みてるぞ f4dea9b91e #34 2026-07-06 19:34:11 +09:00
12個のファイルの変更1152行の追加923行の削除
+214
ファイルの表示
@@ -362,6 +362,37 @@ 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.
- In TypeScript and TSX, treat short object literals, short returned objects,
short typed object shapes, and short function-call object arguments as
compact associative syntax by default. If they fit locally, keep the opening
`{` on the same line as the first property and keep the closing `}` on the
same line as the final property. Do not explode them into vertical
Prettier-style blocks without a concrete readability or line-length reason.
- In TypeScript and TSX, when a compact associative form is broken across two
lines, keep the opening `{` with the first property and the closing `}` with
the final property. A shape like `{\n key: value,\n}` is wrong even when
line length would permit it.
- In TypeScript and TSX, when a short call such as `apiGet<...> (...)`,
`apiPatch<...> (...)`, `setState (...)`, `updateClientSettings (...)`, or
`window.dispatchEvent (...)` already fits within the local style, do not
add extra vertical layers around the object argument or callback body.
- In TypeScript and TSX, after editing a file, inspect the file tail and
remove meaningless blank lines before `export default` or the end of file.
A stray empty line before `export default` is a minor style offence.
- 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 +411,168 @@ 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.
#### Prettier-style exploded object literals that should stay compact
Bad:
```ts
const next = {
enabled: keyboard?.enabled !== false,
bindings: normaliseKeyboardBindings (keyboard?.bindings),
}
```
Good:
```ts
const next = {
enabled: keyboard?.enabled !== false,
bindings: normaliseKeyboardBindings (keyboard?.bindings) }
```
Also good when short enough:
```ts
const next = { enabled: keyboard?.enabled !== false,
bindings: normaliseKeyboardBindings (keyboard?.bindings) }
```
Rule: for compact associative syntax, keep `{` with the first property and `}`
with the final property unless line length truly forces a different shape.
Do not mechanically apply the common Prettier block form.
Penalty: medium-severity offence. The code still runs, but it violates a core
repository formatting habit that must be preserved consistently.
#### Exploded object arguments inside short calls
Bad:
```ts
updateClientSettings (settings => ({
...settings,
keyboard: next,
}))
```
Good:
```ts
updateClientSettings (settings => ({
...settings,
keyboard: next }))
```
Rule: a call-expression closing `)` must not drift onto its own line, and a
short object argument should not be exploded more than necessary. Keep the
callback's returned object compact when it fits.
Penalty: medium-severity offence. This usually means the edit was formatted
mechanically instead of by reading the local style.
#### Stray blank line before `export default`
Bad:
```ts
const Component: FC = () => {
return <div/>
}
export default Component
```
Good:
```ts
const Component: FC = () => {
return <div/>
}
export default Component
```
Rule: do not leave an extra empty line between the last top-level declaration
and `export default` unless some surrounding file structure genuinely requires
visual separation.
Penalty: minor offence. It is small, but it is easy to avoid and should not
recur.
### 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 +605,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 +628,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 +670,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 +702,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 +731,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 +760,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 +790,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 +815,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 +861,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 +891,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 +917,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
+5 -2
ファイルの表示
@@ -21,6 +21,7 @@ import { applyClientAppearance,
setCachedUserThemeSlots, setCachedUserThemeSlots,
seedClientThemeMode } from '@/lib/settings' seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
@@ -210,8 +211,9 @@ const App: FC = () => {
{import.meta.env.DEV && <DevModeWatermark/>} {import.meta.env.DEV && <DevModeWatermark/>}
<BrowserRouter> <BrowserRouter>
<KeyboardShortcutsProvider>
<DialogueProvider> <DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig <MotionConfig
reducedMotion={ reducedMotion={
animationMode === 'normal' animationMode === 'normal'
@@ -232,8 +234,9 @@ const App: FC = () => {
</MotionConfig> </MotionConfig>
<Toaster/> <Toaster/>
</DialogueProvider>
</KeyboardShortcutsProvider> </KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</BrowserRouter> </BrowserRouter>
</>) </>)
} }
+20 -4
ファイルの表示
@@ -6,6 +6,7 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -35,11 +36,19 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
[to],
)
const url = useMemo (() => { const url = useMemo (() => {
const path = (typeof to === 'string') ? to : createPath (to) return (new URL (path, window.location.origin)).toString ()
return (new URL (path, location.origin)).toString () }, [path])
}, [to]) const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive) const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => { const doPrefetch = async () => {
@@ -84,6 +93,13 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault () ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => { flushSync (() => {
setOverlay (true) setOverlay (true)
}) })
@@ -106,7 +122,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
return ( return (
<a ref={ref} <a ref={ref}
href={typeof to === 'string' ? to : createPath (to)} href={path}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onClick={handleClick} onClick={handleClick}
+1 -1
ファイルの表示
@@ -158,7 +158,7 @@ const BehaviourSettingsSection: FC<Props> = (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="destructive"
disabled={hasUnsavedChanges === false} disabled={hasUnsavedChanges === false}
onClick={handleDiscard}> onClick={handleDiscard}>
+1 -1
ファイルの表示
@@ -167,7 +167,7 @@ const KeyboardSettingsSection: FC<Props> = (
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="destructive"
disabled={!(hasUnsavedChanges)} disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}> onClick={handleDiscard}>
-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
}
+4 -25
ファイルの表示
@@ -38,6 +38,7 @@
--top-nav-root-bg-desktop: #fefce8; --top-nav-root-bg-desktop: #fefce8;
--top-nav-active-bg: #fef08a; --top-nav-active-bg: #fef08a;
--top-nav-submenu-bg: #fef08a; --top-nav-submenu-bg: #fef08a;
--top-nav-mobile-menu-bg: #fef08a;
--top-nav-mobile-active-bg: #fefce8; --top-nav-mobile-active-bg: #fefce8;
--top-nav-brand-link: #db2777; --top-nav-brand-link: #db2777;
--top-nav-menu-link: #1d4ed8; --top-nav-menu-link: #1d4ed8;
@@ -89,6 +90,7 @@
--top-nav-root-bg-desktop: #230505; --top-nav-root-bg-desktop: #230505;
--top-nav-active-bg: #450a0a; --top-nav-active-bg: #450a0a;
--top-nav-submenu-bg: #450a0a; --top-nav-submenu-bg: #450a0a;
--top-nav-mobile-menu-bg: #230505;
--top-nav-mobile-active-bg: #450a0a; --top-nav-mobile-active-bg: #450a0a;
--top-nav-brand-link: #f9a8d4; --top-nav-brand-link: #f9a8d4;
--top-nav-menu-link: #93c5fd; --top-nav-menu-link: #93c5fd;
@@ -105,7 +107,7 @@
} }
a:hover a:hover
{ {
opacity: .85; color: color-mix(in oklab, hsl(var(--theme-link)), white 18%);
} }
} }
@@ -125,29 +127,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'] *
{ {
@@ -260,7 +239,7 @@ body
.top-nav-mobile-menu .top-nav-mobile-menu
{ {
background: var(--top-nav-root-bg-mobile); background: var(--top-nav-mobile-menu-bg);
} }
.top-nav-mobile-active .top-nav-mobile-active
+361 -344
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
+18 -9
ファイルの表示
@@ -25,6 +25,7 @@ import {
getEffectiveKeyBindings, getEffectiveKeyBindings,
setClientKeyboardSettings, setClientKeyboardSettings,
} from '@/lib/settings' } from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type { import type {
KeyBinding, KeyBinding,
@@ -96,6 +97,7 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] = const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ()) useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
@@ -146,6 +148,13 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
} }
}, []) }, [])
const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
const builtinHandlers = useMemo<ShortcutHandlers> ( const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({ () => ({
'global.openShortcutHelp': () => { 'global.openShortcutHelp': () => {
@@ -154,16 +163,16 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
'global.focusSearch': () => { 'global.focusSearch': () => {
focusSearchTarget () focusSearchTarget ()
}, },
'navigation.posts': () => navigate ('/posts'), 'navigation.posts': () => guardedNavigate ('/posts'),
'navigation.tags': () => navigate ('/tags'), 'navigation.tags': () => guardedNavigate ('/tags'),
'navigation.materials': () => navigate ('/materials'), 'navigation.materials': () => guardedNavigate ('/materials'),
'navigation.wiki': () => navigate ('/wiki'), 'navigation.wiki': () => guardedNavigate ('/wiki'),
'settings.account': () => navigate ('/users/settings?tab=account'), 'settings.account': () => guardedNavigate ('/users/settings?tab=account'),
'settings.theme': () => navigate ('/users/settings?tab=theme'), 'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'),
'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'), 'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'),
'settings.behavior': () => navigate ('/users/settings?tab=behavior'), 'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'),
}), }),
[navigate], [guardedNavigate],
) )
const availableActionIds = useMemo<Set<ShortcutActionId>> ( const availableActionIds = useMemo<Set<ShortcutActionId>> (
+69
ファイルの表示
@@ -0,0 +1,69 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, PropsWithChildren } from 'react'
type UnsavedChangesSource = {
dirty: boolean
discard: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean> }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
}, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
if (!(source?.dirty))
return true
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま移動すると、保存していない変更は失われます。',
cancelText: 'このページに残る',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
await source.discard ()
return true
}, [dialogue, source])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true,
registerUnsavedChangesSource,
confirmDiscardNavigation,
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
</UnsavedChangesGuardContext.Provider>)
}
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
const context = useContext (UnsavedChangesGuardContext)
if (context == null)
throw new Error ('UnsavedChangesGuardProvider が必要です.')
return context
}
ファイル差分が大きすぎるため省略します 差分を読込み