コミットを比較
5 コミット
fecbb4b354
...
4e3fb6bef7
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 4e3fb6bef7 | |||
| 3a9ac8af32 | |||
| 7bc9ee1e68 | |||
| 95b4db1fc2 | |||
| 4117bdd31e |
@@ -271,6 +271,9 @@ const value =
|
|||||||
- Preserve the local TSX formatting style.
|
- Preserve the local TSX formatting style.
|
||||||
- Do not normalize TSX to common Prettier-style React formatting unless
|
- Do not normalize TSX to common Prettier-style React formatting unless
|
||||||
explicitly asked.
|
explicitly asked.
|
||||||
|
- Treat TSX formatting rules as hard constraints, not preferences. Before
|
||||||
|
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}`
|
||||||
|
placement and fix violations instead of relying on formatter defaults.
|
||||||
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
|
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
|
||||||
- Put two blank lines before and after top-level `const` function
|
- Put two blank lines before and after top-level `const` function
|
||||||
declarations, unless imports, exports, or file boundaries make that awkward.
|
declarations, unless imports, exports, or file boundaries make that awkward.
|
||||||
@@ -281,14 +284,29 @@ const value =
|
|||||||
- In TypeScript and TSX function declarations, including `const` arrow
|
- In TypeScript and TSX function declarations, including `const` arrow
|
||||||
function declarations, when the parameter list spans multiple lines, always
|
function declarations, when the parameter list spans multiple lines, always
|
||||||
put the closing parenthesis at the beginning of its own line before the return
|
put the closing parenthesis at the beginning of its own line before the return
|
||||||
type or `=>`.
|
type or `=>`. Do not put that closing `)` at the end of the previous line.
|
||||||
|
- Do not write React component declarations in the common Prettier form
|
||||||
|
`const Component = ({ ... }) => (` when the destructured parameter spans
|
||||||
|
multiple lines. Use the project form with the opening `(` after `=`, the
|
||||||
|
destructured object as the argument, and the closing parameter `)` on its own
|
||||||
|
line before `=>`.
|
||||||
- In TypeScript and TSX, never place a closing parenthesis at the beginning of
|
- In TypeScript and TSX, never place a closing parenthesis at the beginning of
|
||||||
a line except for a multi-line function declaration parameter list.
|
a line except for a multi-line function declaration parameter list.
|
||||||
- Never place a closing square bracket at the beginning of a line.
|
- Never place a closing square bracket at the beginning of a line.
|
||||||
- For object literals and other associative-array-style braces, do not place
|
- For object literals, type literals, import/export named bindings, destructuring
|
||||||
the closing brace at the beginning of a line. Function, lambda, callback, and
|
patterns, and other associative-array-style braces, do not place the closing
|
||||||
block closing braces are exempt and should stay on their own line when that
|
brace at the beginning of a line. Keep `}` on the same line as the final
|
||||||
fits the local style.
|
property, binding, or specifier unless that would violate the line limit.
|
||||||
|
Function, lambda, callback, and block closing braces are exempt and should
|
||||||
|
stay on their own line when that fits the local style. When a function,
|
||||||
|
lambda, callback, or block body spans multiple lines, do not put its closing
|
||||||
|
`}` at the end of the previous line.
|
||||||
|
- For arrays and tuple-like lists, do not place the closing `]` at the
|
||||||
|
beginning of a line. Keep `]` on the same line as the final element unless
|
||||||
|
that would violate the line limit.
|
||||||
|
- For function and method calls, do not place the closing `)` at the beginning
|
||||||
|
of a line. The only TypeScript/TSX exception is the closing parameter `)` of a
|
||||||
|
multi-line function declaration.
|
||||||
- When writing braces on a single line in TypeScript or TSX JavaScript
|
- When writing braces on a single line in TypeScript or TSX JavaScript
|
||||||
context, put exactly one space inside the braces, as in `{ value }` or
|
context, put exactly one space inside the braces, as in `{ value }` or
|
||||||
`{ key: value }`.
|
`{ key: value }`.
|
||||||
|
|||||||
@@ -126,9 +126,38 @@ pass or the remaining failure is clearly blocked.
|
|||||||
|
|
||||||
- Preserve compact TSX expression shapes such as inline ternary branches and
|
- Preserve compact TSX expression shapes such as inline ternary branches and
|
||||||
closing `</div>)` forms when nearby code uses them.
|
closing `</div>)` forms when nearby code uses them.
|
||||||
|
- Treat TSX formatting rules as hard constraints, not preferences. Before
|
||||||
|
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}`
|
||||||
|
placement and fix violations instead of relying on formatter defaults.
|
||||||
- For long Tailwind `className` strings, wrap across lines only when needed.
|
- For long Tailwind `className` strings, wrap across lines only when needed.
|
||||||
- Keep continuation indentation aligned with the 4-space logical indentation
|
- Keep continuation indentation aligned with the 4-space logical indentation
|
||||||
rule, using tabs only as leading 8-space compression.
|
rule, using tabs only as leading 8-space compression.
|
||||||
|
- In TypeScript and TSX function declarations, including `const` arrow
|
||||||
|
function declarations, when the parameter list spans multiple lines, always
|
||||||
|
put the closing parenthesis at the beginning of its own line before the return
|
||||||
|
type or `=>`. Do not put that closing `)` at the end of the previous line.
|
||||||
|
- Do not write React component declarations in the common Prettier form
|
||||||
|
`const Component = ({ ... }) => (` when the destructured parameter spans
|
||||||
|
multiple lines. Use the project form with the opening `(` after `=`, the
|
||||||
|
destructured object as the argument, and the closing parameter `)` on its own
|
||||||
|
line before `=>`.
|
||||||
|
- In TypeScript and TSX, never place a closing parenthesis at the beginning of
|
||||||
|
a line except for a multi-line function declaration parameter list.
|
||||||
|
- Never place a closing square bracket at the beginning of a line.
|
||||||
|
- For object literals, type literals, import/export named bindings, destructuring
|
||||||
|
patterns, and other associative-array-style braces, do not place the closing
|
||||||
|
brace at the beginning of a line. Keep `}` on the same line as the final
|
||||||
|
property, binding, or specifier unless that would violate the line limit.
|
||||||
|
Function, lambda, callback, and block closing braces are exempt and should
|
||||||
|
stay on their own line when that fits the local style. When a function,
|
||||||
|
lambda, callback, or block body spans multiple lines, do not put its closing
|
||||||
|
`}` at the end of the previous line.
|
||||||
|
- For arrays and tuple-like lists, do not place the closing `]` at the
|
||||||
|
beginning of a line. Keep `]` on the same line as the final element unless
|
||||||
|
that would violate the line limit.
|
||||||
|
- For function and method calls, do not place the closing `)` at the beginning
|
||||||
|
of a line. The only TypeScript/TSX exception is the closing parameter `)` of a
|
||||||
|
multi-line function declaration.
|
||||||
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
|
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
|
||||||
single physical line.
|
single physical line.
|
||||||
- Always add braces around `if`, `else`, or `for` bodies when the body spans
|
- Always add braces around `if`, `else`, or `for` bodies when the body spans
|
||||||
|
|||||||
+15
-12
@@ -18,6 +18,7 @@ import {
|
|||||||
getClientThemeMode,
|
getClientThemeMode,
|
||||||
seedClientThemeMode,
|
seedClientThemeMode,
|
||||||
} from '@/lib/settings'
|
} from '@/lib/settings'
|
||||||
|
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||||
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
||||||
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
||||||
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
|
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
|
||||||
@@ -185,19 +186,21 @@ const App: FC = () => {
|
|||||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||||
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<DialogueProvider>
|
<KeyboardShortcutsProvider>
|
||||||
<LayoutGroup>
|
<DialogueProvider>
|
||||||
<motion.div
|
<LayoutGroup>
|
||||||
layout="position"
|
<motion.div
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
layout="position"
|
||||||
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
<TopNav user={user}/>
|
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
<TopNav user={user}/>
|
||||||
</motion.div>
|
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||||
</LayoutGroup>
|
</motion.div>
|
||||||
|
</LayoutGroup>
|
||||||
|
|
||||||
<Toaster/>
|
<Toaster/>
|
||||||
</DialogueProvider>
|
</DialogueProvider>
|
||||||
|
</KeyboardShortcutsProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
|||||||
import { prefetchForURL } from '@/lib/prefetchers'
|
import { prefetchForURL } from '@/lib/prefetchers'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
|
import type { AnchorHTMLAttributes, KeyboardEvent, MouseEvent, TouchEvent } from 'react'
|
||||||
import type { To } from 'react-router-dom'
|
import type { To } from 'react-router-dom'
|
||||||
|
|
||||||
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||||
@@ -25,6 +25,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
|||||||
state,
|
state,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onTouchStart,
|
onTouchStart,
|
||||||
|
onKeyDown,
|
||||||
onClick,
|
onClick,
|
||||||
cancelOnError = false,
|
cancelOnError = false,
|
||||||
...rest }, ref) => {
|
...rest }, ref) => {
|
||||||
@@ -97,11 +98,22 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (ev: KeyboardEvent<HTMLAnchorElement>) => {
|
||||||
|
onKeyDown?.(ev)
|
||||||
|
|
||||||
|
if (ev.defaultPrevented)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (ev.key === ' ' || ev.key === 'Spacebar')
|
||||||
|
ev.preventDefault ()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a ref={ref}
|
<a ref={ref}
|
||||||
href={typeof to === 'string' ? to : createPath (to)}
|
href={typeof to === 'string' ? to : createPath (to)}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn ('cursor-pointer', className)}
|
className={cn ('cursor-pointer', className)}
|
||||||
{...rest}/>)
|
{...rest}/>)
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const TagSearch: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
data-shortcut-focus-search="true"
|
||||||
placeholder="タグ検索..."
|
placeholder="タグ検索..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={whenChanged}
|
onChange={whenChanged}
|
||||||
|
|||||||
@@ -249,16 +249,16 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
<TopNavUser user={user}/>
|
<TopNavUser user={user}/>
|
||||||
|
|
||||||
<a href="#"
|
<button
|
||||||
className="md:hidden ml-auto pr-4
|
type="button"
|
||||||
|
className="md:hidden ml-auto border-0 bg-transparent pr-4
|
||||||
text-pink-600 hover:text-pink-400
|
text-pink-600 hover:text-pink-400
|
||||||
dark:text-pink-300 dark:hover:text-pink-100"
|
dark:text-pink-300 dark:hover:text-pink-100"
|
||||||
onClick={ev => {
|
onClick={() => {
|
||||||
ev.preventDefault ()
|
|
||||||
setMenuOpen (!(menuOpen))
|
setMenuOpen (!(menuOpen))
|
||||||
}}>
|
}}>
|
||||||
{menuOpen ? '×' : 'Menu'}
|
{menuOpen ? '×' : 'Menu'}
|
||||||
</a>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
|
|||||||
@@ -28,15 +28,19 @@ const TabGroup: FC<Props> = ({ children, rightAddon }) => {
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex min-w-0 gap-4">
|
<div className="flex min-w-0 gap-4">
|
||||||
{tabs.map ((tab, i) => (
|
{tabs.map ((tab, i) => (
|
||||||
<a key={i}
|
<button
|
||||||
href="#"
|
key={i}
|
||||||
className={cn (i === current && 'font-bold')}
|
type="button"
|
||||||
onClick={ev => {
|
className={cn (
|
||||||
ev.preventDefault ()
|
'bg-transparent p-0 text-left text-[hsl(var(--theme-link))] hover:opacity-85',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
i === current && 'font-bold',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
setCurrent (i)
|
setCurrent (i)
|
||||||
}}>
|
}}>
|
||||||
{tab.props.name}
|
{tab.props.name}
|
||||||
</a>))}
|
</button>))}
|
||||||
</div>
|
</div>
|
||||||
{rightAddon && (
|
{rightAddon && (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from '@/components/ui/use-toast'
|
||||||
|
import {
|
||||||
|
getConflictingShortcutActionIds,
|
||||||
|
formatKeyBinding,
|
||||||
|
isSpaceBinding,
|
||||||
|
keyBindingFromKeyboardEvent,
|
||||||
|
SHORTCUT_DEFINITIONS,
|
||||||
|
SHORTCUT_SCOPE_LABELS,
|
||||||
|
} from '@/lib/keyboardShortcuts'
|
||||||
|
import { getEffectiveKeyBindings } from '@/lib/settings'
|
||||||
|
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
|
||||||
|
|
||||||
|
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
|
||||||
|
import type { ClientKeyboardSettings } from '@/lib/settings'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sectionClassName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
|
||||||
|
const {
|
||||||
|
keyboardSettings,
|
||||||
|
saveKeyboardSettings,
|
||||||
|
} = useKeyboardShortcutSettings ()
|
||||||
|
const [capturingActionId, setCapturingActionId] =
|
||||||
|
useState<ShortcutActionId | null> (null)
|
||||||
|
const [draftKeyboardSettings, setDraftKeyboardSettings] =
|
||||||
|
useState<ClientKeyboardSettings> (keyboardSettings)
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setDraftKeyboardSettings (keyboardSettings)
|
||||||
|
}, [keyboardSettings])
|
||||||
|
|
||||||
|
const draftEffectiveBindings = useMemo (
|
||||||
|
() => getEffectiveKeyBindings (draftKeyboardSettings.bindings),
|
||||||
|
[draftKeyboardSettings.bindings],
|
||||||
|
)
|
||||||
|
const draftConflictActionIds = useMemo (
|
||||||
|
() => getConflictingShortcutActionIds (draftEffectiveBindings),
|
||||||
|
[draftEffectiveBindings],
|
||||||
|
)
|
||||||
|
const hasUnsavedChanges = useMemo (
|
||||||
|
() => JSON.stringify (draftKeyboardSettings) !== JSON.stringify (keyboardSettings),
|
||||||
|
[draftKeyboardSettings, keyboardSettings],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (capturingActionId == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.isComposing || event.key === 'Process')
|
||||||
|
return
|
||||||
|
|
||||||
|
event.preventDefault ()
|
||||||
|
event.stopPropagation ()
|
||||||
|
|
||||||
|
if (event.key === 'Escape')
|
||||||
|
{
|
||||||
|
setCapturingActionId (null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace' || event.key === 'Delete')
|
||||||
|
{
|
||||||
|
setDraftKeyboardSettings (current => ({
|
||||||
|
...current,
|
||||||
|
bindings: {
|
||||||
|
...(current.bindings ?? { }),
|
||||||
|
[capturingActionId]: null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
setCapturingActionId (null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = keyBindingFromKeyboardEvent (event)
|
||||||
|
if (binding == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (isSpaceBinding (binding))
|
||||||
|
{
|
||||||
|
toast ({ title: 'Space は今は割り当てられません.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraftKeyboardSettings (current => ({
|
||||||
|
...current,
|
||||||
|
bindings: {
|
||||||
|
...(current.bindings ?? { }),
|
||||||
|
[capturingActionId]: binding,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
setCapturingActionId (null)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener ('keydown', onKeyDown, true)
|
||||||
|
return () => document.removeEventListener ('keydown', onKeyDown, true)
|
||||||
|
}, [capturingActionId])
|
||||||
|
|
||||||
|
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
|
||||||
|
...definition,
|
||||||
|
currentBinding: draftEffectiveBindings[definition.id],
|
||||||
|
hasConflict: draftConflictActionIds.has (definition.id),
|
||||||
|
hasCustomBinding: Object.prototype.hasOwnProperty.call (
|
||||||
|
draftKeyboardSettings.bindings ?? { },
|
||||||
|
definition.id,
|
||||||
|
),
|
||||||
|
})), [
|
||||||
|
draftConflictActionIds,
|
||||||
|
draftEffectiveBindings,
|
||||||
|
draftKeyboardSettings.bindings,
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
saveKeyboardSettings (draftKeyboardSettings)
|
||||||
|
toast ({ title: 'キーボード設定を保存しました' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
setDraftKeyboardSettings (keyboardSettings)
|
||||||
|
setCapturingActionId (null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={sectionClassName}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-xl font-bold">キーボード</h2>
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
未保存の変更があります
|
||||||
|
</p>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDraftKeyboardSettings (current => ({
|
||||||
|
...current,
|
||||||
|
bindings: { },
|
||||||
|
}))
|
||||||
|
}}>
|
||||||
|
すべて既定に戻す
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!(hasUnsavedChanges)}
|
||||||
|
onClick={handleDiscard}>
|
||||||
|
変更を破棄
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
|
||||||
|
onClick={handleSave}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draftKeyboardSettings.enabled !== false}
|
||||||
|
onChange={event => {
|
||||||
|
setDraftKeyboardSettings (current => ({
|
||||||
|
...current,
|
||||||
|
enabled: event.target.checked,
|
||||||
|
}))
|
||||||
|
}}/>
|
||||||
|
<span>キーボード・ショートカットを有効にする</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{capturingActionId != null && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます.
|
||||||
|
</p>)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
|
||||||
|
<thead className="border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2 text-left">操作</th>
|
||||||
|
<th className="p-2 text-left">適用範囲</th>
|
||||||
|
<th className="p-2 text-left">現在のキー</th>
|
||||||
|
<th className="p-2 text-left">変更</th>
|
||||||
|
<th className="p-2 text-left">既定に戻す</th>
|
||||||
|
<th className="p-2 text-left">競合状態</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map (row => (
|
||||||
|
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
|
||||||
|
<td className="p-2">{row.label}</td>
|
||||||
|
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
|
||||||
|
<td className="p-2 font-mono">
|
||||||
|
{formatKeyBinding (row.currentBinding)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={capturingActionId === row.id ? 'default' : 'outline'}
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => {
|
||||||
|
setCapturingActionId (
|
||||||
|
capturingActionId === row.id ? null : row.id,
|
||||||
|
)
|
||||||
|
}}>
|
||||||
|
{capturingActionId === row.id ? '入力待ち…' : '変更'}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!(row.hasCustomBinding)}
|
||||||
|
onClick={() => {
|
||||||
|
setDraftKeyboardSettings (current => {
|
||||||
|
const nextBindings = { ...(current.bindings ?? { }) }
|
||||||
|
delete nextBindings[row.id]
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
bindings: nextBindings,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
既定に戻す
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
{row.hasConflict
|
||||||
|
? <span className="text-red-600 dark:text-red-400">競合あり</span>
|
||||||
|
: 'なし'}
|
||||||
|
</td>
|
||||||
|
</tr>))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KeyboardSettingsSection
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
formatKeyBinding,
|
||||||
|
SHORTCUT_DEFINITIONS,
|
||||||
|
SHORTCUT_SCOPE_LABELS,
|
||||||
|
} from '@/lib/keyboardShortcuts'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
KeyBinding,
|
||||||
|
ShortcutActionId,
|
||||||
|
ShortcutScope,
|
||||||
|
} from '@/lib/keyboardShortcuts'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
activeScope: ShortcutScope
|
||||||
|
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
|
||||||
|
conflictActionIds: Set<ShortcutActionId>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ShortcutHelpDialog: FC<Props> = (
|
||||||
|
{ open, onOpenChange, activeScope, effectiveBindings, conflictActionIds },
|
||||||
|
) => {
|
||||||
|
const rows = SHORTCUT_DEFINITIONS.filter (definition =>
|
||||||
|
definition.scope === 'global' || definition.scope === activeScope)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
|
||||||
|
<DialogHeader className="pl-8">
|
||||||
|
<DialogTitle>キーボード・ショートカット</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
この画面で利用できるショートカットを表示します。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[40rem] table-fixed border-collapse text-sm">
|
||||||
|
<thead className="border-b border-border">
|
||||||
|
<tr>
|
||||||
|
<th className="p-2 text-left">操作</th>
|
||||||
|
<th className="p-2 text-left">適用範囲</th>
|
||||||
|
<th className="p-2 text-left">キー</th>
|
||||||
|
<th className="p-2 text-left">競合</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map (row => (
|
||||||
|
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
|
||||||
|
<td className="p-2">{row.label}</td>
|
||||||
|
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
|
||||||
|
<td className="p-2 font-mono">
|
||||||
|
{formatKeyBinding (effectiveBindings[row.id])}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
{conflictActionIds.has (row.id)
|
||||||
|
? <span className="text-red-600 dark:text-red-400">競合あり</span>
|
||||||
|
: 'なし'}
|
||||||
|
</td>
|
||||||
|
</tr>))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShortcutHelpDialog
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
export type ShortcutScope =
|
||||||
|
| 'global'
|
||||||
|
| 'posts'
|
||||||
|
| 'postSearch'
|
||||||
|
| 'tags'
|
||||||
|
| 'settings'
|
||||||
|
| 'wiki'
|
||||||
|
| 'materials'
|
||||||
|
|
||||||
|
export type ShortcutActionId =
|
||||||
|
| 'global.openShortcutHelp'
|
||||||
|
| 'global.focusSearch'
|
||||||
|
| 'navigation.posts'
|
||||||
|
| 'navigation.tags'
|
||||||
|
| 'navigation.materials'
|
||||||
|
| 'navigation.wiki'
|
||||||
|
| 'pagination.next'
|
||||||
|
| 'pagination.previous'
|
||||||
|
| 'settings.account'
|
||||||
|
| 'settings.theme'
|
||||||
|
| 'settings.keyboard'
|
||||||
|
|
||||||
|
export type KeyBinding = {
|
||||||
|
key: string
|
||||||
|
ctrl?: boolean
|
||||||
|
shift?: boolean
|
||||||
|
alt?: boolean
|
||||||
|
meta?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShortcutDefinition = {
|
||||||
|
id: ShortcutActionId
|
||||||
|
label: string
|
||||||
|
scope: ShortcutScope
|
||||||
|
defaultBinding: KeyBinding | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = {
|
||||||
|
global: '全画面',
|
||||||
|
posts: '広場',
|
||||||
|
postSearch: '広場検索',
|
||||||
|
tags: 'タグ',
|
||||||
|
settings: '設定',
|
||||||
|
wiki: 'Wiki',
|
||||||
|
materials: '素材',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'global.openShortcutHelp',
|
||||||
|
label: 'ショートカット一覧を開く',
|
||||||
|
scope: 'global',
|
||||||
|
defaultBinding: { key: '?', shift: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'global.focusSearch',
|
||||||
|
label: '検索欄へ移動',
|
||||||
|
scope: 'global',
|
||||||
|
defaultBinding: { key: '/' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'navigation.posts',
|
||||||
|
label: '広場へ移動',
|
||||||
|
scope: 'global',
|
||||||
|
defaultBinding: { key: 'p' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'navigation.tags',
|
||||||
|
label: 'タグへ移動',
|
||||||
|
scope: 'global',
|
||||||
|
defaultBinding: { key: 't' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'navigation.materials',
|
||||||
|
label: '素材へ移動',
|
||||||
|
scope: 'global',
|
||||||
|
defaultBinding: { key: 'm' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'navigation.wiki',
|
||||||
|
label: 'Wiki へ移動',
|
||||||
|
scope: 'global',
|
||||||
|
defaultBinding: { key: 'w' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pagination.next',
|
||||||
|
label: '次のページへ移動',
|
||||||
|
scope: 'global',
|
||||||
|
defaultBinding: { key: 'ArrowRight' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pagination.previous',
|
||||||
|
label: '前のページへ移動',
|
||||||
|
scope: 'global',
|
||||||
|
defaultBinding: { key: 'ArrowLeft' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings.account',
|
||||||
|
label: '設定: アカウント',
|
||||||
|
scope: 'settings',
|
||||||
|
defaultBinding: { key: '1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings.theme',
|
||||||
|
label: '設定: テーマ',
|
||||||
|
scope: 'settings',
|
||||||
|
defaultBinding: { key: '2' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings.keyboard',
|
||||||
|
label: '設定: キーボード',
|
||||||
|
scope: 'settings',
|
||||||
|
defaultBinding: { key: '3' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SHORTCUT_DEFINITIONS_BY_ID =
|
||||||
|
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, ShortcutDefinition>> (
|
||||||
|
(definitions, definition) => {
|
||||||
|
definitions[definition.id] = definition
|
||||||
|
return definitions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'global.openShortcutHelp': SHORTCUT_DEFINITIONS[0],
|
||||||
|
'global.focusSearch': SHORTCUT_DEFINITIONS[1],
|
||||||
|
'navigation.posts': SHORTCUT_DEFINITIONS[2],
|
||||||
|
'navigation.tags': SHORTCUT_DEFINITIONS[3],
|
||||||
|
'navigation.materials': SHORTCUT_DEFINITIONS[4],
|
||||||
|
'navigation.wiki': SHORTCUT_DEFINITIONS[5],
|
||||||
|
'pagination.next': SHORTCUT_DEFINITIONS[6],
|
||||||
|
'pagination.previous': SHORTCUT_DEFINITIONS[7],
|
||||||
|
'settings.account': SHORTCUT_DEFINITIONS[8],
|
||||||
|
'settings.theme': SHORTCUT_DEFINITIONS[9],
|
||||||
|
'settings.keyboard': SHORTCUT_DEFINITIONS[10],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const DEFAULT_KEY_BINDINGS =
|
||||||
|
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, KeyBinding | null>> (
|
||||||
|
(bindings, definition) => {
|
||||||
|
bindings[definition.id] = definition.defaultBinding
|
||||||
|
return bindings
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'global.openShortcutHelp': null,
|
||||||
|
'global.focusSearch': null,
|
||||||
|
'navigation.posts': null,
|
||||||
|
'navigation.tags': null,
|
||||||
|
'navigation.materials': null,
|
||||||
|
'navigation.wiki': null,
|
||||||
|
'pagination.next': null,
|
||||||
|
'pagination.previous': null,
|
||||||
|
'settings.account': null,
|
||||||
|
'settings.theme': null,
|
||||||
|
'settings.keyboard': null,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const normaliseKey = (key: string): string => {
|
||||||
|
if (key === 'Spacebar')
|
||||||
|
return ' '
|
||||||
|
if (key.length === 1 && key !== ' ')
|
||||||
|
return key.toLowerCase ()
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
const isModifierKey = (key: string): boolean =>
|
||||||
|
key === 'Shift'
|
||||||
|
|| key === 'Control'
|
||||||
|
|| key === 'Alt'
|
||||||
|
|| key === 'Meta'
|
||||||
|
|
||||||
|
export const normaliseKeyBinding = (value: unknown): KeyBinding | null => {
|
||||||
|
if (!(value) || typeof value !== 'object')
|
||||||
|
return null
|
||||||
|
|
||||||
|
const binding = value as Partial<KeyBinding>
|
||||||
|
if (typeof binding.key !== 'string')
|
||||||
|
return null
|
||||||
|
|
||||||
|
const key = normaliseKey (binding.key)
|
||||||
|
if (key.length === 0 || isModifierKey (key))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
...(binding.ctrl ? { ctrl: true } : { }),
|
||||||
|
...(binding.shift ? { shift: true } : { }),
|
||||||
|
...(binding.alt ? { alt: true } : { }),
|
||||||
|
...(binding.meta ? { meta: true } : { }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serialiseKeyBinding = (binding: KeyBinding | null): string | null => {
|
||||||
|
if (binding == null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
binding.ctrl ? 'Ctrl' : null,
|
||||||
|
binding.shift ? 'Shift' : null,
|
||||||
|
binding.alt ? 'Alt' : null,
|
||||||
|
binding.meta ? 'Meta' : null,
|
||||||
|
binding.key,
|
||||||
|
].filter ((part): part is string => part != null)
|
||||||
|
|
||||||
|
return parts.join ('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyLabel = (key: string): string => {
|
||||||
|
switch (key)
|
||||||
|
{
|
||||||
|
case ' ':
|
||||||
|
return 'Space'
|
||||||
|
|
||||||
|
case 'ArrowLeft':
|
||||||
|
return 'ArrowLeft'
|
||||||
|
|
||||||
|
case 'ArrowRight':
|
||||||
|
return 'ArrowRight'
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
return 'ArrowUp'
|
||||||
|
|
||||||
|
case 'ArrowDown':
|
||||||
|
return 'ArrowDown'
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
return 'Escape'
|
||||||
|
|
||||||
|
case 'Backspace':
|
||||||
|
return 'Backspace'
|
||||||
|
|
||||||
|
case 'Delete':
|
||||||
|
return 'Delete'
|
||||||
|
|
||||||
|
default:
|
||||||
|
return key.length === 1 ? key.toUpperCase () : key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatKeyBinding = (binding: KeyBinding | null): string => {
|
||||||
|
if (binding == null)
|
||||||
|
return '未割当'
|
||||||
|
|
||||||
|
return [
|
||||||
|
binding.ctrl ? 'Ctrl' : null,
|
||||||
|
binding.shift ? 'Shift' : null,
|
||||||
|
binding.alt ? 'Alt' : null,
|
||||||
|
binding.meta ? 'Meta' : null,
|
||||||
|
keyLabel (binding.key),
|
||||||
|
].filter ((part): part is string => part != null)
|
||||||
|
.join (' + ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const keyBindingFromKeyboardEvent = (
|
||||||
|
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>,
|
||||||
|
): KeyBinding | null => {
|
||||||
|
const key = normaliseKey (event.key)
|
||||||
|
if (key.length === 0 || isModifierKey (key))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
...(event.ctrlKey ? { ctrl: true } : { }),
|
||||||
|
...(event.shiftKey ? { shift: true } : { }),
|
||||||
|
...(event.altKey ? { alt: true } : { }),
|
||||||
|
...(event.metaKey ? { meta: true } : { }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isEditableEventTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element))
|
||||||
|
return false
|
||||||
|
|
||||||
|
return target.closest (
|
||||||
|
'input, textarea, select, [contenteditable="true"], [role="textbox"]',
|
||||||
|
) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getConflictingShortcutActionIds = (
|
||||||
|
bindings: Record<ShortcutActionId, KeyBinding | null>,
|
||||||
|
): Set<ShortcutActionId> => {
|
||||||
|
const grouped = new Map<string, ShortcutActionId[]> ()
|
||||||
|
|
||||||
|
Object.entries (bindings).forEach (([actionId, binding]) => {
|
||||||
|
const serialised = serialiseKeyBinding (binding)
|
||||||
|
if (serialised == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const ids = grouped.get (serialised) ?? []
|
||||||
|
ids.push (actionId as ShortcutActionId)
|
||||||
|
grouped.set (serialised, ids)
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Set (
|
||||||
|
[...grouped.values ()]
|
||||||
|
.filter (actionIds => actionIds.length > 1)
|
||||||
|
.flat (),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveShortcutScope = (pathname: string): ShortcutScope => {
|
||||||
|
if (pathname.startsWith ('/posts/search'))
|
||||||
|
return 'postSearch'
|
||||||
|
if (pathname.startsWith ('/posts'))
|
||||||
|
return 'posts'
|
||||||
|
if (pathname.startsWith ('/tags'))
|
||||||
|
return 'tags'
|
||||||
|
if (pathname.startsWith ('/users/settings'))
|
||||||
|
return 'settings'
|
||||||
|
if (pathname.startsWith ('/wiki'))
|
||||||
|
return 'wiki'
|
||||||
|
if (pathname.startsWith ('/materials'))
|
||||||
|
return 'materials'
|
||||||
|
return 'global'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSpaceBinding = (binding: KeyBinding | null): boolean =>
|
||||||
|
binding?.key === ' '
|
||||||
+130
-1
@@ -1,7 +1,9 @@
|
|||||||
import { CATEGORIES } from '@/consts'
|
import { CATEGORIES } from '@/consts'
|
||||||
import { apiGet, apiPatch } from '@/lib/api'
|
import { apiGet, apiPatch } from '@/lib/api'
|
||||||
|
import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts'
|
||||||
|
|
||||||
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
|
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
|
||||||
|
import type { KeyBinding, ShortcutActionId } from '@/lib/keyboardShortcuts'
|
||||||
|
|
||||||
export type UserSettings = {
|
export type UserSettings = {
|
||||||
theme: 'system' | 'light' | 'dark'
|
theme: 'system' | 'light' | 'dark'
|
||||||
@@ -61,6 +63,11 @@ type ClientListSettings = {
|
|||||||
limit?: ClientListLimit
|
limit?: ClientListLimit
|
||||||
order?: string }
|
order?: string }
|
||||||
|
|
||||||
|
export type ClientKeyboardSettings = {
|
||||||
|
enabled?: boolean
|
||||||
|
bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>>
|
||||||
|
}
|
||||||
|
|
||||||
export type ClientAppearanceSettings = {
|
export type ClientAppearanceSettings = {
|
||||||
activeThemeId?: ActiveThemeId
|
activeThemeId?: ActiveThemeId
|
||||||
customThemes?: Record<string, CustomClientTheme>
|
customThemes?: Record<string, CustomClientTheme>
|
||||||
@@ -80,6 +87,7 @@ export const DEFAULT_USER_SETTINGS: UserSettings = {
|
|||||||
// Browser-local settings only. These are device or screen specific.
|
// Browser-local settings only. These are device or screen specific.
|
||||||
export type ClientSettings = {
|
export type ClientSettings = {
|
||||||
appearance?: ClientAppearanceSettings
|
appearance?: ClientAppearanceSettings
|
||||||
|
keyboard?: ClientKeyboardSettings
|
||||||
panes?: Record<string, ClientPaneSettings>
|
panes?: Record<string, ClientPaneSettings>
|
||||||
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
||||||
theatre?: {
|
theatre?: {
|
||||||
@@ -212,6 +220,15 @@ const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY =
|
|||||||
const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated'
|
const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated'
|
||||||
|
|
||||||
|
|
||||||
|
const createCustomThemeId = (): `custom:${ string }` => {
|
||||||
|
const randomPart =
|
||||||
|
globalThis.crypto?.randomUUID?.()
|
||||||
|
?? `${ Date.now ().toString (36) }-${ Math.random ().toString (36).slice (2) }`
|
||||||
|
|
||||||
|
return `custom:${ randomPart }`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchUserSettings = async (): Promise<UserSettings> => {
|
export const fetchUserSettings = async (): Promise<UserSettings> => {
|
||||||
const raw = await apiGet<{
|
const raw = await apiGet<{
|
||||||
theme: UserSettings['theme']
|
theme: UserSettings['theme']
|
||||||
@@ -286,6 +303,118 @@ const validListLimit = (value: unknown): ClientListLimit | null =>
|
|||||||
value === 20 || value === 50 || value === 100 ? value : null
|
value === 20 || value === 50 || value === 100 ? value : null
|
||||||
|
|
||||||
|
|
||||||
|
const normaliseKeyboardBindings = (
|
||||||
|
bindings: unknown,
|
||||||
|
): Partial<Record<ShortcutActionId, KeyBinding | null>> => {
|
||||||
|
if (!(bindings) || typeof bindings !== 'object')
|
||||||
|
return { }
|
||||||
|
|
||||||
|
return Object.keys (DEFAULT_KEY_BINDINGS).reduce<
|
||||||
|
Partial<Record<ShortcutActionId, KeyBinding | null>>
|
||||||
|
> (
|
||||||
|
(normalised, actionId) => {
|
||||||
|
const value = (bindings as Record<string, unknown>)[actionId]
|
||||||
|
if (value === null)
|
||||||
|
{
|
||||||
|
normalised[actionId as ShortcutActionId] = null
|
||||||
|
return normalised
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = normaliseKeyBinding (value)
|
||||||
|
if (binding != null)
|
||||||
|
normalised[actionId as ShortcutActionId] = binding
|
||||||
|
|
||||||
|
return normalised
|
||||||
|
},
|
||||||
|
{ },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getClientKeyboardSettings = (): ClientKeyboardSettings => {
|
||||||
|
const keyboard = loadClientSettings ().keyboard
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: keyboard?.enabled !== false,
|
||||||
|
bindings: normaliseKeyboardBindings (keyboard?.bindings),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const setClientKeyboardSettings = (
|
||||||
|
keyboard: ClientKeyboardSettings,
|
||||||
|
): ClientKeyboardSettings => {
|
||||||
|
const next: ClientKeyboardSettings = {
|
||||||
|
enabled: keyboard.enabled !== false,
|
||||||
|
bindings: normaliseKeyboardBindings (keyboard.bindings),
|
||||||
|
}
|
||||||
|
|
||||||
|
updateClientSettings (settings => ({
|
||||||
|
...settings,
|
||||||
|
keyboard: next,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getEffectiveKeyBindings = (
|
||||||
|
bindings: Partial<Record<ShortcutActionId, KeyBinding | null>> =
|
||||||
|
getClientKeyboardSettings ().bindings ?? { },
|
||||||
|
): Record<ShortcutActionId, KeyBinding | null> =>
|
||||||
|
Object.keys (DEFAULT_KEY_BINDINGS).reduce<Record<ShortcutActionId, KeyBinding | null>> (
|
||||||
|
(effectiveBindings, actionId) => {
|
||||||
|
const key = actionId as ShortcutActionId
|
||||||
|
effectiveBindings[key] =
|
||||||
|
Object.prototype.hasOwnProperty.call (bindings, key)
|
||||||
|
? bindings[key] ?? null
|
||||||
|
: DEFAULT_KEY_BINDINGS[key]
|
||||||
|
return effectiveBindings
|
||||||
|
},
|
||||||
|
{ ...DEFAULT_KEY_BINDINGS },
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
export const setClientKeyBinding = (
|
||||||
|
actionId: ShortcutActionId,
|
||||||
|
binding: KeyBinding | null,
|
||||||
|
): ClientKeyboardSettings => {
|
||||||
|
const current = getClientKeyboardSettings ()
|
||||||
|
|
||||||
|
return setClientKeyboardSettings ({
|
||||||
|
...current,
|
||||||
|
bindings: {
|
||||||
|
...(current.bindings ?? { }),
|
||||||
|
[actionId]: binding,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const resetClientKeyBinding = (
|
||||||
|
actionId: ShortcutActionId,
|
||||||
|
): ClientKeyboardSettings => {
|
||||||
|
const current = getClientKeyboardSettings ()
|
||||||
|
const nextBindings = { ...(current.bindings ?? { }) }
|
||||||
|
delete nextBindings[actionId]
|
||||||
|
|
||||||
|
return setClientKeyboardSettings ({
|
||||||
|
...current,
|
||||||
|
bindings: nextBindings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const resetAllClientKeyBindings = (): ClientKeyboardSettings => {
|
||||||
|
const current = getClientKeyboardSettings ()
|
||||||
|
|
||||||
|
return setClientKeyboardSettings ({
|
||||||
|
...current,
|
||||||
|
bindings: { },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const normaliseHexColour = (value: string): string | null =>
|
const normaliseHexColour = (value: string): string | null =>
|
||||||
/^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null
|
/^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null
|
||||||
|
|
||||||
@@ -567,7 +696,7 @@ export const createCustomThemeFromBase = (
|
|||||||
baseTheme: BuiltinThemeId,
|
baseTheme: BuiltinThemeId,
|
||||||
tokens?: ThemeTokens,
|
tokens?: ThemeTokens,
|
||||||
): CustomClientTheme => {
|
): CustomClientTheme => {
|
||||||
const id = `custom:${ crypto.randomUUID () }` as ThemeId
|
const id = createCustomThemeId ()
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: themeCopyName (baseTheme),
|
name: themeCopyName (baseTheme),
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
|
||||||
|
import {
|
||||||
|
DEFAULT_KEY_BINDINGS,
|
||||||
|
getConflictingShortcutActionIds,
|
||||||
|
isEditableEventTarget,
|
||||||
|
keyBindingFromKeyboardEvent,
|
||||||
|
resolveShortcutScope,
|
||||||
|
serialiseKeyBinding,
|
||||||
|
SHORTCUT_DEFINITIONS,
|
||||||
|
SHORTCUT_SCOPE_LABELS,
|
||||||
|
} from '@/lib/keyboardShortcuts'
|
||||||
|
import {
|
||||||
|
getClientKeyboardSettings,
|
||||||
|
getEffectiveKeyBindings,
|
||||||
|
resetAllClientKeyBindings,
|
||||||
|
resetClientKeyBinding,
|
||||||
|
setClientKeyBinding,
|
||||||
|
setClientKeyboardSettings,
|
||||||
|
} from '@/lib/settings'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
KeyBinding,
|
||||||
|
ShortcutActionId,
|
||||||
|
ShortcutScope,
|
||||||
|
} from '@/lib/keyboardShortcuts'
|
||||||
|
import type { ClientKeyboardSettings } from '@/lib/settings'
|
||||||
|
import type { PropsWithChildren } from 'react'
|
||||||
|
|
||||||
|
type ShortcutHandler = () => void
|
||||||
|
type ShortcutHandlers = Partial<Record<ShortcutActionId, ShortcutHandler>>
|
||||||
|
|
||||||
|
type KeyboardShortcutsContextValue = {
|
||||||
|
activeScope: ShortcutScope
|
||||||
|
keyboardSettings: ClientKeyboardSettings
|
||||||
|
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
|
||||||
|
conflictActionIds: Set<ShortcutActionId>
|
||||||
|
saveKeyboardSettings: (settings: ClientKeyboardSettings) => void
|
||||||
|
setEnabled: (enabled: boolean) => void
|
||||||
|
setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void
|
||||||
|
resetBinding: (actionId: ShortcutActionId) => void
|
||||||
|
resetAllBindings: () => void
|
||||||
|
registerHandlers: (handlers: ShortcutHandlers) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeyboardShortcutsContext =
|
||||||
|
createContext<KeyboardShortcutsContextValue | null> (null)
|
||||||
|
|
||||||
|
|
||||||
|
const isFocusableShortcutTarget = (element: HTMLElement): boolean => {
|
||||||
|
if (element.closest ('[hidden], [aria-hidden="true"]') != null)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if (
|
||||||
|
(element instanceof HTMLInputElement
|
||||||
|
|| element instanceof HTMLButtonElement
|
||||||
|
|| element instanceof HTMLSelectElement
|
||||||
|
|| element instanceof HTMLTextAreaElement)
|
||||||
|
&& element.disabled
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const style = window.getComputedStyle (element)
|
||||||
|
if (
|
||||||
|
style.display === 'none'
|
||||||
|
|| style.visibility === 'hidden'
|
||||||
|
|| style.pointerEvents === 'none'
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return element.getClientRects ().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const focusSearchTarget = (): void => {
|
||||||
|
const targets = [
|
||||||
|
...document.querySelectorAll<HTMLElement> (
|
||||||
|
'[data-shortcut-focus-search="true"]',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
const target = targets.find (isFocusableShortcutTarget)
|
||||||
|
|
||||||
|
if (target == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
target.focus ()
|
||||||
|
|
||||||
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
|
||||||
|
target.select ()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
||||||
|
const location = useLocation ()
|
||||||
|
const navigate = useNavigate ()
|
||||||
|
|
||||||
|
const [keyboardSettings, setKeyboardSettingsState] =
|
||||||
|
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
|
||||||
|
const [registeredHandlers, setRegisteredHandlers] =
|
||||||
|
useState<Record<number, ShortcutHandlers>> ({ })
|
||||||
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
|
||||||
|
const registrationIdRef = useRef (0)
|
||||||
|
|
||||||
|
const activeScope = useMemo<ShortcutScope> (
|
||||||
|
() => resolveShortcutScope (location.pathname),
|
||||||
|
[location.pathname],
|
||||||
|
)
|
||||||
|
const effectiveBindings = useMemo (
|
||||||
|
() => getEffectiveKeyBindings (keyboardSettings.bindings),
|
||||||
|
[keyboardSettings.bindings],
|
||||||
|
)
|
||||||
|
const conflictActionIds = useMemo (
|
||||||
|
() => getConflictingShortcutActionIds (effectiveBindings),
|
||||||
|
[effectiveBindings],
|
||||||
|
)
|
||||||
|
const mergedRegisteredHandlers = useMemo<ShortcutHandlers> (
|
||||||
|
() => Object.values (registeredHandlers).reduce<ShortcutHandlers> (
|
||||||
|
(handlers, currentHandlers) => ({ ...handlers, ...currentHandlers }),
|
||||||
|
{ },
|
||||||
|
),
|
||||||
|
[registeredHandlers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setEnabled = useCallback ((enabled: boolean) => {
|
||||||
|
const next = setClientKeyboardSettings ({
|
||||||
|
...keyboardSettings,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
setKeyboardSettingsState (next)
|
||||||
|
}, [keyboardSettings])
|
||||||
|
|
||||||
|
const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => {
|
||||||
|
const next = setClientKeyboardSettings (settings)
|
||||||
|
setKeyboardSettingsState (next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setBinding = useCallback ((
|
||||||
|
actionId: ShortcutActionId,
|
||||||
|
binding: KeyBinding | null,
|
||||||
|
) => {
|
||||||
|
const next = setClientKeyBinding (actionId, binding)
|
||||||
|
setKeyboardSettingsState (next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetBinding = useCallback ((actionId: ShortcutActionId) => {
|
||||||
|
const next = resetClientKeyBinding (actionId)
|
||||||
|
setKeyboardSettingsState (next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetAllBindings = useCallback (() => {
|
||||||
|
const next = resetAllClientKeyBindings ()
|
||||||
|
setKeyboardSettingsState (next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const registerHandlers = useCallback ((handlers: ShortcutHandlers) => {
|
||||||
|
const registrationId = ++registrationIdRef.current
|
||||||
|
|
||||||
|
setRegisteredHandlers (current => ({
|
||||||
|
...current,
|
||||||
|
[registrationId]: handlers,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setRegisteredHandlers (current => {
|
||||||
|
const next = { ...current }
|
||||||
|
delete next[registrationId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
keyboardSettings.enabled === false
|
||||||
|
|| event.defaultPrevented
|
||||||
|
|| event.isComposing
|
||||||
|
|| event.key === 'Process'
|
||||||
|
|| event.repeat
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (isEditableEventTarget (event.target))
|
||||||
|
return
|
||||||
|
|
||||||
|
if (shortcutHelpOpen)
|
||||||
|
return
|
||||||
|
|
||||||
|
const binding = keyBindingFromKeyboardEvent (event)
|
||||||
|
if (binding == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const serialisedBinding = serialiseKeyBinding (binding)
|
||||||
|
if (serialisedBinding == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const candidates = SHORTCUT_DEFINITIONS.filter (definition => (
|
||||||
|
definition.scope === 'global'
|
||||||
|
|| definition.scope === activeScope
|
||||||
|
)).filter (definition => {
|
||||||
|
const currentBinding = effectiveBindings[definition.id]
|
||||||
|
return (
|
||||||
|
currentBinding != null
|
||||||
|
&& serialiseKeyBinding (currentBinding) === serialisedBinding
|
||||||
|
)
|
||||||
|
}).filter (definition => !(conflictActionIds.has (definition.id)))
|
||||||
|
|
||||||
|
if (candidates.length !== 1)
|
||||||
|
return
|
||||||
|
|
||||||
|
const actionId = candidates[0].id
|
||||||
|
const builtinHandler = (
|
||||||
|
{
|
||||||
|
'global.openShortcutHelp': () => {
|
||||||
|
setShortcutHelpOpen (true)
|
||||||
|
},
|
||||||
|
'global.focusSearch': () => {
|
||||||
|
focusSearchTarget ()
|
||||||
|
},
|
||||||
|
'navigation.posts': () => navigate ('/posts'),
|
||||||
|
'navigation.tags': () => navigate ('/tags'),
|
||||||
|
'navigation.materials': () => navigate ('/materials'),
|
||||||
|
'navigation.wiki': () => navigate ('/wiki'),
|
||||||
|
'settings.account': () => navigate ('/users/settings?tab=account'),
|
||||||
|
'settings.theme': () => navigate ('/users/settings?tab=theme'),
|
||||||
|
'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'),
|
||||||
|
} as ShortcutHandlers
|
||||||
|
)[actionId]
|
||||||
|
const handler = builtinHandler ?? mergedRegisteredHandlers[actionId]
|
||||||
|
|
||||||
|
if (handler == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
event.preventDefault ()
|
||||||
|
handler ()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener ('keydown', onKeyDown)
|
||||||
|
return () => document.removeEventListener ('keydown', onKeyDown)
|
||||||
|
}, [
|
||||||
|
activeScope,
|
||||||
|
conflictActionIds,
|
||||||
|
effectiveBindings,
|
||||||
|
keyboardSettings.enabled,
|
||||||
|
mergedRegisteredHandlers,
|
||||||
|
navigate,
|
||||||
|
shortcutHelpOpen,
|
||||||
|
])
|
||||||
|
|
||||||
|
const value = useMemo<KeyboardShortcutsContextValue> (() => ({
|
||||||
|
activeScope,
|
||||||
|
keyboardSettings,
|
||||||
|
effectiveBindings,
|
||||||
|
conflictActionIds,
|
||||||
|
setEnabled,
|
||||||
|
saveKeyboardSettings,
|
||||||
|
setBinding,
|
||||||
|
resetBinding,
|
||||||
|
resetAllBindings,
|
||||||
|
registerHandlers,
|
||||||
|
}), [
|
||||||
|
activeScope,
|
||||||
|
conflictActionIds,
|
||||||
|
effectiveBindings,
|
||||||
|
keyboardSettings,
|
||||||
|
registerHandlers,
|
||||||
|
resetAllBindings,
|
||||||
|
resetBinding,
|
||||||
|
saveKeyboardSettings,
|
||||||
|
setBinding,
|
||||||
|
setEnabled,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardShortcutsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<ShortcutHelpDialog
|
||||||
|
open={shortcutHelpOpen}
|
||||||
|
onOpenChange={setShortcutHelpOpen}
|
||||||
|
activeScope={activeScope}
|
||||||
|
effectiveBindings={effectiveBindings}
|
||||||
|
conflictActionIds={conflictActionIds}/>
|
||||||
|
</KeyboardShortcutsContext.Provider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => {
|
||||||
|
const context = useContext (KeyboardShortcutsContext)
|
||||||
|
|
||||||
|
if (context == null)
|
||||||
|
throw new Error ('KeyboardShortcutsProvider が必要です.')
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
|
||||||
|
const { registerHandlers } = useKeyboardShortcutSettings ()
|
||||||
|
|
||||||
|
useEffect (
|
||||||
|
() => registerHandlers (handlers),
|
||||||
|
[handlers, registerHandlers],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DEFAULT_KEY_BINDINGS,
|
||||||
|
SHORTCUT_DEFINITIONS,
|
||||||
|
SHORTCUT_SCOPE_LABELS,
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
LIST_LIMIT_OPTIONS,
|
LIST_LIMIT_OPTIONS,
|
||||||
setClientListSettings,
|
setClientListSettings,
|
||||||
} from '@/lib/settings'
|
} from '@/lib/settings'
|
||||||
|
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||||
import { fetchWikiPageByTitle } from '@/lib/wiki'
|
import { fetchWikiPageByTitle } from '@/lib/wiki'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
@@ -68,6 +69,12 @@ const PostListPage: FC = () => {
|
|||||||
setClientListSettings ('postList', { limit })
|
setClientListSettings ('postList', { limit })
|
||||||
}, [limit])
|
}, [limit])
|
||||||
|
|
||||||
|
const updatePage = (nextPage: number) => {
|
||||||
|
const params = new URLSearchParams (location.search)
|
||||||
|
params.set ('page', String (Math.max (1, nextPage)))
|
||||||
|
navigate (`${ location.pathname }?${ params.toString () }`)
|
||||||
|
}
|
||||||
|
|
||||||
const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => {
|
const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => {
|
||||||
const params = new URLSearchParams (location.search)
|
const params = new URLSearchParams (location.search)
|
||||||
params.set ('limit', String (next.limit ?? limit))
|
params.set ('limit', String (next.limit ?? limit))
|
||||||
@@ -75,6 +82,23 @@ const PostListPage: FC = () => {
|
|||||||
navigate (`${ location.pathname }?${ params.toString () }`)
|
navigate (`${ location.pathname }?${ params.toString () }`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useKeyboardShortcuts (useMemo (() => ({
|
||||||
|
...(page > 1
|
||||||
|
? {
|
||||||
|
'pagination.previous': () => {
|
||||||
|
updatePage (page - 1)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { }),
|
||||||
|
...(totalPages > 0 && page < totalPages
|
||||||
|
? {
|
||||||
|
'pagination.next': () => {
|
||||||
|
updatePage (Math.min (page + 1, totalPages))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { }),
|
||||||
|
}), [page, totalPages, location.search]))
|
||||||
|
|
||||||
useLayoutEffect (() => {
|
useLayoutEffect (() => {
|
||||||
scroll (0, 0)
|
scroll (0, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
LIST_LIMIT_OPTIONS,
|
LIST_LIMIT_OPTIONS,
|
||||||
setClientListSettings,
|
setClientListSettings,
|
||||||
} from '@/lib/settings'
|
} from '@/lib/settings'
|
||||||
|
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||||
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
import type { FC, FormEvent } from 'react'
|
||||||
@@ -171,11 +172,34 @@ const PostSearchPage: FC = () => {
|
|||||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatePage = (nextPage: number) => {
|
||||||
|
const qs = new URLSearchParams (location.search)
|
||||||
|
qs.set ('page', String (Math.max (1, nextPage)))
|
||||||
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
search ()
|
search ()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useKeyboardShortcuts (useMemo (() => ({
|
||||||
|
...(page > 1
|
||||||
|
? {
|
||||||
|
'pagination.previous': () => {
|
||||||
|
updatePage (page - 1)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { }),
|
||||||
|
...(totalPages > 0 && page < totalPages
|
||||||
|
? {
|
||||||
|
'pagination.next': () => {
|
||||||
|
updatePage (Math.min (page + 1, totalPages))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { }),
|
||||||
|
}), [page, totalPages, location.search]))
|
||||||
|
|
||||||
const defaultDirection = { title: 'asc',
|
const defaultDirection = { title: 'asc',
|
||||||
url: 'asc',
|
url: 'asc',
|
||||||
original_created_at: 'desc',
|
original_created_at: 'desc',
|
||||||
@@ -214,6 +238,7 @@ const PostSearchPage: FC = () => {
|
|||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
data-shortcut-focus-search="true"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={e => setTitle (e.target.value)}
|
onChange={e => setTitle (e.target.value)}
|
||||||
className={inputClass (invalid)}/>)}
|
className={inputClass (invalid)}/>)}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
LIST_LIMIT_OPTIONS,
|
LIST_LIMIT_OPTIONS,
|
||||||
setClientListSettings,
|
setClientListSettings,
|
||||||
} from '@/lib/settings'
|
} from '@/lib/settings'
|
||||||
|
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||||
import { fetchTags } from '@/lib/tags'
|
import { fetchTags } from '@/lib/tags'
|
||||||
import { dateString, inputClass } from '@/lib/utils'
|
import { dateString, inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -180,12 +181,35 @@ const TagListPage: FC = () => {
|
|||||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatePage = (nextPage: number) => {
|
||||||
|
const qs = new URLSearchParams (location.search)
|
||||||
|
qs.set ('page', String (Math.max (1, nextPage)))
|
||||||
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||||
|
}
|
||||||
|
|
||||||
const defaultDirection = { name: 'asc',
|
const defaultDirection = { name: 'asc',
|
||||||
category: 'asc',
|
category: 'asc',
|
||||||
post_count: 'desc',
|
post_count: 'desc',
|
||||||
created_at: 'desc',
|
created_at: 'desc',
|
||||||
updated_at: 'desc' } as const
|
updated_at: 'desc' } as const
|
||||||
|
|
||||||
|
useKeyboardShortcuts (useMemo (() => ({
|
||||||
|
...(page > 1
|
||||||
|
? {
|
||||||
|
'pagination.previous': () => {
|
||||||
|
updatePage (page - 1)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { }),
|
||||||
|
...(totalPages > 0 && page < totalPages
|
||||||
|
? {
|
||||||
|
'pagination.next': () => {
|
||||||
|
updatePage (Math.min (page + 1, totalPages))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { }),
|
||||||
|
}), [page, totalPages, location.search]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -218,6 +242,7 @@ const TagListPage: FC = () => {
|
|||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
data-shortcut-focus-search="true"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName (e.target.value)}
|
onChange={e => setName (e.target.value)}
|
||||||
className={inputClass (invalid)}/>)}
|
className={inputClass (invalid)}/>)}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
@@ -10,6 +8,7 @@ import Label from '@/components/common/Label'
|
|||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
|
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
|
||||||
import InheritDialogue from '@/components/users/InheritDialogue'
|
import InheritDialogue from '@/components/users/InheritDialogue'
|
||||||
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
|
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
|
||||||
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
|
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
|
||||||
@@ -17,37 +16,35 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiPut } from '@/lib/api'
|
import { apiPut } from '@/lib/api'
|
||||||
import {
|
import { applyThemeSelection,
|
||||||
applyThemeSelection,
|
BUILTIN_THEMES,
|
||||||
BUILTIN_THEMES,
|
createCustomThemeFromBase,
|
||||||
createCustomThemeFromBase,
|
defaultThemeTagColoursFor,
|
||||||
defaultThemeTagColoursFor,
|
DEFAULT_USER_SETTINGS,
|
||||||
DEFAULT_USER_SETTINGS,
|
deriveUserThemeModeFromSelection,
|
||||||
deriveUserThemeModeFromSelection,
|
fetchUserSettings,
|
||||||
fetchUserSettings,
|
hasStoredClientThemeSelection,
|
||||||
hasStoredClientThemeSelection,
|
getClientActiveThemeId,
|
||||||
getClientActiveThemeId,
|
getClientCustomThemes,
|
||||||
getClientCustomThemes,
|
getResolvedThemeFromSelection,
|
||||||
getResolvedThemeFromSelection,
|
setClientAppearanceThemes,
|
||||||
setClientAppearanceThemes,
|
updateUserSettings } from '@/lib/settings'
|
||||||
updateUserSettings,
|
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
|
||||||
} from '@/lib/settings'
|
|
||||||
import { cn, inputClass } from '@/lib/utils'
|
import { cn, inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
|
|
||||||
|
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
|
||||||
|
|
||||||
import type { Category, Tag, User } from '@/types'
|
import type { Category, Tag, User } from '@/types'
|
||||||
import type {
|
import type { ActiveThemeId,
|
||||||
ActiveThemeId,
|
ClientTheme,
|
||||||
ClientTheme,
|
CustomClientTheme,
|
||||||
CustomClientTheme,
|
ThemeTagColours,
|
||||||
ThemeTagColours,
|
UserSettings } from '@/lib/settings'
|
||||||
UserSettings,
|
|
||||||
} from '@/lib/settings'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User | null
|
user: User | null
|
||||||
setUser: Dispatch<SetStateAction<User | null>>
|
setUser: Dispatch<SetStateAction<User | null>> }
|
||||||
}
|
|
||||||
|
|
||||||
type UserFormField = 'name'
|
type UserFormField = 'name'
|
||||||
type SettingsFormField = 'theme'
|
type SettingsFormField = 'theme'
|
||||||
@@ -71,19 +68,18 @@ type SharedSectionProps = {
|
|||||||
selectedTheme: ClientTheme
|
selectedTheme: ClientTheme
|
||||||
draftTagColours: ThemeTagColours
|
draftTagColours: ThemeTagColours
|
||||||
onThemeChange: (themeId: ActiveThemeId) => void
|
onThemeChange: (themeId: ActiveThemeId) => void
|
||||||
|
onEnsureEditableTheme: () => void
|
||||||
onTagColourChange: (category: Category, colour: string) => void
|
onTagColourChange: (category: Category, colour: string) => void
|
||||||
onResetAllTagColours: () => void
|
onResetAllTagColours: () => void
|
||||||
onResetTagColour: (category: Category) => void
|
onResetTagColour: (category: Category) => void
|
||||||
onThemeSubmit: () => void
|
onThemeSubmit: () => void
|
||||||
settingsBaseErrors: string[]
|
settingsBaseErrors: string[]
|
||||||
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
|
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>> }
|
||||||
}
|
|
||||||
|
|
||||||
const tabs: TabSpec[] = [
|
const tabs: TabSpec[] = [
|
||||||
{ id: 'account', label: 'アカウント' },
|
{ id: 'account', label: 'アカウント' },
|
||||||
{ id: 'theme', label: 'テーマ' },
|
{ id: 'theme', label: 'テーマ' },
|
||||||
{ id: 'keyboard', label: 'キーボード' },
|
{ id: 'keyboard', label: 'キーボード' } ]
|
||||||
]
|
|
||||||
|
|
||||||
const sectionClassName =
|
const sectionClassName =
|
||||||
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
||||||
@@ -91,34 +87,12 @@ const sectionClassName =
|
|||||||
const themeLabel: Record<UserSettings['theme'], string> = {
|
const themeLabel: Record<UserSettings['theme'], string> = {
|
||||||
system: 'システム設定に従う',
|
system: 'システム設定に従う',
|
||||||
light: 'ライト・モード',
|
light: 'ライト・モード',
|
||||||
dark: 'ダーク・モード',
|
dark: 'ダーク・モード' }
|
||||||
}
|
|
||||||
|
|
||||||
const builtinThemeLabel: Record<'light' | 'dark', string> = {
|
const builtinThemeLabel: Record<'light' | 'dark', string> = {
|
||||||
light: 'ライト・モード',
|
light: 'ライト・モード',
|
||||||
dark: 'ダーク・モード',
|
dark: 'ダーク・モード' }
|
||||||
}
|
|
||||||
|
|
||||||
const keyboardShortcutRows = [
|
|
||||||
{
|
|
||||||
action: '投稿詳細を開く',
|
|
||||||
key: 'Enter',
|
|
||||||
scope: '一覧・検索結果',
|
|
||||||
conflict: 'なし',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: '候補タグを選ぶ',
|
|
||||||
key: '↑ / ↓ / Enter',
|
|
||||||
scope: 'タグ入力',
|
|
||||||
conflict: 'なし',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: '候補タグを閉じる',
|
|
||||||
key: 'Escape',
|
|
||||||
scope: 'タグ入力',
|
|
||||||
conflict: 'なし',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const previewTag = (
|
const previewTag = (
|
||||||
id: number,
|
id: number,
|
||||||
@@ -140,8 +114,7 @@ const previewTag = (
|
|||||||
hasWiki,
|
hasWiki,
|
||||||
materialId: null,
|
materialId: null,
|
||||||
hasDeerjikists: options?.hasDeerjikists ?? false,
|
hasDeerjikists: options?.hasDeerjikists ?? false,
|
||||||
children: [],
|
children: [] })
|
||||||
})
|
|
||||||
|
|
||||||
const previewTags: Record<Category, Tag> = {
|
const previewTags: Record<Category, Tag> = {
|
||||||
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
|
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
|
||||||
@@ -150,15 +123,14 @@ const previewTags: Record<Category, Tag> = {
|
|||||||
general: previewTag (4, '一般', 'general', 78, true),
|
general: previewTag (4, '一般', 'general', 78, true),
|
||||||
material: previewTag (5, '素材', 'material', 90, false),
|
material: previewTag (5, '素材', 'material', 90, false),
|
||||||
meta: previewTag (6, 'メタ', 'meta', 21, true),
|
meta: previewTag (6, 'メタ', 'meta', 21, true),
|
||||||
nico: previewTag (7, 'nico:sm9', 'nico', 43, false),
|
nico: previewTag (7, 'nico:sm9', 'nico', 43, false) }
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const parseTab = (search: string): SettingsTab => {
|
const parseTab = (search: string): SettingsTab => {
|
||||||
const tab = new URLSearchParams (search).get ('tab')
|
const tab = new URLSearchParams (search).get ('tab')
|
||||||
return tabs.some (candidate => candidate.id === tab)
|
return (tabs.some (candidate => candidate.id === tab)
|
||||||
? tab as SettingsTab
|
? (tab as SettingsTab)
|
||||||
: 'account'
|
: 'account')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -170,9 +142,7 @@ const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
|
|||||||
Object.fromEntries (
|
Object.fromEntries (
|
||||||
CATEGORIES.flatMap (category => [
|
CATEGORIES.flatMap (category => [
|
||||||
[`--tag-colour-${ category }`, tagColours[category]],
|
[`--tag-colour-${ category }`, tagColours[category]],
|
||||||
[`--tag-colour-${ category }-hover`, tagColours[category]],
|
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
|
||||||
]),
|
|
||||||
) as CSSProperties
|
|
||||||
|
|
||||||
|
|
||||||
const themeLabelById = (
|
const themeLabelById = (
|
||||||
@@ -187,16 +157,16 @@ const themeLabelById = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const AccountSection: FC<SharedSectionProps> = ({
|
const AccountSection: FC<SharedSectionProps> = (
|
||||||
name,
|
{ name,
|
||||||
onInheritOpen,
|
onInheritOpen,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
onUserCodeOpen,
|
onUserCodeOpen,
|
||||||
onUserSubmit,
|
onUserSubmit,
|
||||||
user,
|
user,
|
||||||
userBaseErrors,
|
userBaseErrors,
|
||||||
userFieldErrors,
|
userFieldErrors },
|
||||||
}) => (
|
) => (
|
||||||
<section className={sectionClassName}>
|
<section className={sectionClassName}>
|
||||||
<h2 className="text-xl font-bold">アカウント</h2>
|
<h2 className="text-xl font-bold">アカウント</h2>
|
||||||
<FieldError messages={userBaseErrors}/>
|
<FieldError messages={userBaseErrors}/>
|
||||||
@@ -247,19 +217,20 @@ const AccountSection: FC<SharedSectionProps> = ({
|
|||||||
</section>)
|
</section>)
|
||||||
|
|
||||||
|
|
||||||
const ThemeSection: FC<SharedSectionProps> = ({
|
const ThemeSection: FC<SharedSectionProps> = (
|
||||||
draftActiveThemeId,
|
{ draftActiveThemeId,
|
||||||
draftCustomThemes,
|
draftCustomThemes,
|
||||||
selectedTheme,
|
selectedTheme,
|
||||||
draftTagColours,
|
draftTagColours,
|
||||||
onResetAllTagColours,
|
onEnsureEditableTheme,
|
||||||
onResetTagColour,
|
onResetAllTagColours,
|
||||||
onTagColourChange,
|
onResetTagColour,
|
||||||
onThemeChange,
|
onTagColourChange,
|
||||||
onThemeSubmit,
|
onThemeChange,
|
||||||
settingsBaseErrors,
|
onThemeSubmit,
|
||||||
settingsFieldErrors,
|
settingsBaseErrors,
|
||||||
}) => (
|
settingsFieldErrors },
|
||||||
|
) => (
|
||||||
<section className={sectionClassName}>
|
<section className={sectionClassName}>
|
||||||
<h2 className="text-xl font-bold">テーマ</h2>
|
<h2 className="text-xl font-bold">テーマ</h2>
|
||||||
<FieldError messages={settingsBaseErrors}/>
|
<FieldError messages={settingsBaseErrors}/>
|
||||||
@@ -283,28 +254,10 @@ const ThemeSection: FC<SharedSectionProps> = ({
|
|||||||
</select>)}
|
</select>)}
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-dashed border-border/70 p-3 text-sm">
|
|
||||||
<p>
|
|
||||||
テーマの選択状態は現在のブラウザに保存し,背景・文字色はそのテーマの base に従います.
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
backend には system / light / dark の mode だけ保存します.
|
|
||||||
カスタムテーマ本体は今は browser-local です.
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
将来は themes テーブルなどへ移して,端末間同期できる構造に拡張できます.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">タグカテゴリ色</h3>
|
<h3 className="font-medium">タグカテゴリ色</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{selectedTheme.builtin || draftActiveThemeId === 'system'
|
|
||||||
? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.'
|
|
||||||
: `現在の編輯対象: ${ selectedTheme.name }`}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -331,6 +284,8 @@ const ThemeSection: FC<SharedSectionProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={draftTagColours[category]}
|
value={draftTagColours[category]}
|
||||||
|
onPointerDown={onEnsureEditableTheme}
|
||||||
|
onFocus={onEnsureEditableTheme}
|
||||||
onChange={ev => onTagColourChange (category, ev.target.value)}
|
onChange={ev => onTagColourChange (category, ev.target.value)}
|
||||||
className="h-10 w-16 cursor-pointer rounded border border-border
|
className="h-10 w-16 cursor-pointer rounded border border-border
|
||||||
bg-transparent p-1"/>
|
bg-transparent p-1"/>
|
||||||
@@ -371,53 +326,12 @@ const ThemeSection: FC<SharedSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-dashed border-border/70 p-3 text-sm">
|
|
||||||
<p className="font-medium">既存 backend 設定について</p>
|
|
||||||
<p>
|
|
||||||
auto_fetch_title、auto_fetch_thumbnail、wiki_editor_mode は backend 側に
|
|
||||||
保持したままです.この共通設定画面にはまだ接続していません.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="button" onClick={onThemeSubmit}>
|
<Button type="button" onClick={onThemeSubmit}>
|
||||||
テーマを保存
|
テーマを保存
|
||||||
</Button>
|
</Button>
|
||||||
</section>)
|
</section>)
|
||||||
|
|
||||||
|
|
||||||
const KeyboardSection: FC = () => (
|
|
||||||
<section className={sectionClassName}>
|
|
||||||
<h2 className="text-xl font-bold">キーボード</h2>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
現段階では一覧表示のみです.将来は action / key / scope / conflict を持つ
|
|
||||||
key bind 設定へ拡張できる形にしています.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[28rem] table-fixed border-collapse text-sm">
|
|
||||||
<thead className="border-b border-border">
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 text-left">操作</th>
|
|
||||||
<th className="p-2 text-left">キー</th>
|
|
||||||
<th className="p-2 text-left">適用範囲</th>
|
|
||||||
<th className="p-2 text-left">競合</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{keyboardShortcutRows.map (row => (
|
|
||||||
<tr key={row.action} className="border-b border-border/60 last:border-b-0">
|
|
||||||
<td className="p-2">{row.action}</td>
|
|
||||||
<td className="p-2 font-mono">{row.key}</td>
|
|
||||||
<td className="p-2">{row.scope}</td>
|
|
||||||
<td className="p-2">{row.conflict}</td>
|
|
||||||
</tr>))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>)
|
|
||||||
|
|
||||||
|
|
||||||
const SettingPage: FC<Props> = ({ user, setUser }) => {
|
const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
@@ -436,24 +350,21 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
const [draftCustomThemes, setDraftCustomThemes] =
|
const [draftCustomThemes, setDraftCustomThemes] =
|
||||||
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
|
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
|
||||||
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
||||||
const {
|
const { conflictActionIds } = useKeyboardShortcutSettings ()
|
||||||
baseErrors: userBaseErrors,
|
const { baseErrors: userBaseErrors,
|
||||||
fieldErrors: userFieldErrors,
|
fieldErrors: userFieldErrors,
|
||||||
clearValidationErrors: clearUserValidationErrors,
|
clearValidationErrors: clearUserValidationErrors,
|
||||||
applyValidationError: applyUserValidationError,
|
applyValidationError: applyUserValidationError } = useValidationErrors<UserFormField> ()
|
||||||
} = useValidationErrors<UserFormField> ()
|
const { baseErrors: settingsBaseErrors,
|
||||||
const {
|
fieldErrors: settingsFieldErrors,
|
||||||
baseErrors: settingsBaseErrors,
|
clearValidationErrors: clearSettingsValidationErrors,
|
||||||
fieldErrors: settingsFieldErrors,
|
applyValidationError: applySettingsValidationError } =
|
||||||
clearValidationErrors: clearSettingsValidationErrors,
|
useValidationErrors<SettingsFormField> ()
|
||||||
applyValidationError: applySettingsValidationError,
|
|
||||||
} = useValidationErrors<SettingsFormField> ()
|
|
||||||
|
|
||||||
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
|
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
|
||||||
const selectedTheme = useMemo<ClientTheme> (
|
const selectedTheme = useMemo<ClientTheme> (
|
||||||
() => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes),
|
() => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes),
|
||||||
[draftActiveThemeId, draftCustomThemes],
|
[draftActiveThemeId, draftCustomThemes])
|
||||||
)
|
|
||||||
const draftTagColours = selectedTheme.tokens.tagColours
|
const draftTagColours = selectedTheme.tokens.tagColours
|
||||||
|
|
||||||
const setActiveTab = (tab: SettingsTab) => {
|
const setActiveTab = (tab: SettingsTab) => {
|
||||||
@@ -461,15 +372,15 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
params.set ('tab', tab)
|
params.set ('tab', tab)
|
||||||
navigate (
|
navigate (
|
||||||
`${ location.pathname }?${ params.toString () }`,
|
`${ location.pathname }?${ params.toString () }`,
|
||||||
{ replace: true },
|
{ replace: true })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (() => ({
|
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
|
||||||
account: Boolean (userFieldErrors.name?.length),
|
() => (
|
||||||
theme: Boolean (settingsFieldErrors.theme?.length),
|
{ account: Boolean (userFieldErrors.name?.length),
|
||||||
keyboard: false,
|
theme: Boolean (settingsFieldErrors.theme?.length),
|
||||||
}), [settingsFieldErrors.theme, userFieldErrors.name])
|
keyboard: conflictActionIds.size > 0 }),
|
||||||
|
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name])
|
||||||
|
|
||||||
const applyDraftThemeSelection = (
|
const applyDraftThemeSelection = (
|
||||||
activeThemeId: ActiveThemeId,
|
activeThemeId: ActiveThemeId,
|
||||||
@@ -479,39 +390,42 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
setDraftCustomThemes (customThemes)
|
setDraftCustomThemes (customThemes)
|
||||||
setDraftSettings (current => ({
|
setDraftSettings (current => ({
|
||||||
...current,
|
...current,
|
||||||
theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes),
|
theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes) }))
|
||||||
}))
|
|
||||||
applyThemeSelection (activeThemeId, customThemes)
|
applyThemeSelection (activeThemeId, customThemes)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureEditableCustomTheme = (): {
|
const ensureEditableCustomTheme = (): {
|
||||||
activeThemeId: ActiveThemeId
|
activeThemeId: ActiveThemeId
|
||||||
customThemes: Record<string, CustomClientTheme>
|
customThemes: Record<string, CustomClientTheme> } => {
|
||||||
} => {
|
|
||||||
if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null)
|
if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null)
|
||||||
{
|
{
|
||||||
return {
|
return { activeThemeId: draftActiveThemeId,
|
||||||
activeThemeId: draftActiveThemeId,
|
customThemes: draftCustomThemes }
|
||||||
customThemes: draftCustomThemes,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const customTheme = createCustomThemeFromBase (
|
const customTheme = createCustomThemeFromBase (
|
||||||
selectedTheme.baseTheme,
|
selectedTheme.baseTheme,
|
||||||
{
|
{ ...selectedTheme.tokens,
|
||||||
...selectedTheme.tokens,
|
tagColours: { ...selectedTheme.tokens.tagColours } })
|
||||||
tagColours: { ...selectedTheme.tokens.tagColours },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const nextCustomThemes = {
|
const nextCustomThemes = {
|
||||||
...draftCustomThemes,
|
...draftCustomThemes,
|
||||||
[customTheme.id]: customTheme,
|
[customTheme.id]: customTheme }
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeThemeId: customTheme.id,
|
activeThemeId: customTheme.id,
|
||||||
customThemes: nextCustomThemes,
|
customThemes: nextCustomThemes }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEnsureEditableTheme = (): {
|
||||||
|
activeThemeId: ActiveThemeId
|
||||||
|
customThemes: Record<string, CustomClientTheme> } => {
|
||||||
|
const editable = ensureEditableCustomTheme ()
|
||||||
|
|
||||||
|
if (editable.activeThemeId !== draftActiveThemeId
|
||||||
|
|| editable.customThemes !== draftCustomThemes)
|
||||||
|
applyDraftThemeSelection (editable.activeThemeId, editable.customThemes)
|
||||||
|
|
||||||
|
return editable
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
@@ -612,9 +526,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
const customThemes = getClientCustomThemes ()
|
const customThemes = getClientCustomThemes ()
|
||||||
const activeThemeId = hasStoredClientThemeSelection ()
|
const activeThemeId = (hasStoredClientThemeSelection ()
|
||||||
? getClientActiveThemeId ()
|
? getClientActiveThemeId ()
|
||||||
: data.theme
|
: data.theme)
|
||||||
const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes)
|
const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes)
|
||||||
|
|
||||||
setDraftSettings ({ ...data, theme: themeMode })
|
setDraftSettings ({ ...data, theme: themeMode })
|
||||||
@@ -661,68 +575,62 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
}
|
}
|
||||||
}, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes])
|
}, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes])
|
||||||
|
|
||||||
const sectionProps: SharedSectionProps | null = user ? {
|
const sectionProps: SharedSectionProps | null =
|
||||||
user,
|
user
|
||||||
name,
|
? { user,
|
||||||
onNameChange: setName,
|
name,
|
||||||
onInheritOpen: () => setInheritVsbl (true),
|
onNameChange: setName,
|
||||||
onUserCodeOpen: () => setUserCodeVsbl (true),
|
onInheritOpen: () => setInheritVsbl (true),
|
||||||
onUserSubmit: handleUserSubmit,
|
onUserCodeOpen: () => setUserCodeVsbl (true),
|
||||||
userBaseErrors,
|
onUserSubmit: handleUserSubmit,
|
||||||
userFieldErrors,
|
userBaseErrors,
|
||||||
draftActiveThemeId,
|
userFieldErrors,
|
||||||
draftCustomThemes,
|
draftActiveThemeId,
|
||||||
selectedTheme,
|
draftCustomThemes,
|
||||||
draftTagColours,
|
selectedTheme,
|
||||||
onThemeChange: themeId => {
|
draftTagColours,
|
||||||
applyDraftThemeSelection (themeId, draftCustomThemes)
|
onThemeChange: themeId => {
|
||||||
},
|
applyDraftThemeSelection (themeId, draftCustomThemes)
|
||||||
onTagColourChange: (category, colour) => {
|
},
|
||||||
const editable = ensureEditableCustomTheme ()
|
onEnsureEditableTheme: () => {
|
||||||
const editableTheme = editable.customThemes[editable.activeThemeId]
|
handleEnsureEditableTheme ()
|
||||||
const nextCustomThemes = {
|
},
|
||||||
...editable.customThemes,
|
onTagColourChange: (category, colour) => {
|
||||||
[editable.activeThemeId]: {
|
const editable = handleEnsureEditableTheme ()
|
||||||
...editableTheme,
|
const editableTheme = editable.customThemes[editable.activeThemeId]
|
||||||
tokens: {
|
const nextCustomThemes = {
|
||||||
...editableTheme.tokens,
|
...editable.customThemes,
|
||||||
tagColours: {
|
[editable.activeThemeId]: {
|
||||||
...editableTheme.tokens.tagColours,
|
...editableTheme,
|
||||||
[category]: colour,
|
tokens: {
|
||||||
},
|
...editableTheme.tokens,
|
||||||
},
|
tagColours: {
|
||||||
},
|
...editableTheme.tokens.tagColours,
|
||||||
}
|
[category]: colour } } } }
|
||||||
applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes)
|
applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes)
|
||||||
},
|
},
|
||||||
onResetAllTagColours: () => {
|
onResetAllTagColours: () => {
|
||||||
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
||||||
return
|
return
|
||||||
|
|
||||||
applyDraftThemeSelection (
|
applyDraftThemeSelection (
|
||||||
draftActiveThemeId,
|
draftActiveThemeId,
|
||||||
{
|
{
|
||||||
...draftCustomThemes,
|
...draftCustomThemes,
|
||||||
[draftActiveThemeId]: {
|
[draftActiveThemeId]: {
|
||||||
...draftCustomThemes[draftActiveThemeId],
|
...draftCustomThemes[draftActiveThemeId],
|
||||||
tokens: {
|
tokens: {
|
||||||
...draftCustomThemes[draftActiveThemeId].tokens,
|
...draftCustomThemes[draftActiveThemeId].tokens,
|
||||||
tagColours: {
|
tagColours: {
|
||||||
...defaultThemeTagColoursFor (selectedTheme.baseTheme),
|
...defaultThemeTagColoursFor (selectedTheme.baseTheme) } } } })
|
||||||
},
|
},
|
||||||
},
|
onResetTagColour: category => {
|
||||||
},
|
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
||||||
},
|
return
|
||||||
)
|
|
||||||
},
|
|
||||||
onResetTagColour: category => {
|
|
||||||
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
|
||||||
return
|
|
||||||
|
|
||||||
applyDraftThemeSelection (
|
applyDraftThemeSelection (
|
||||||
draftActiveThemeId,
|
draftActiveThemeId,
|
||||||
{
|
{ ...draftCustomThemes,
|
||||||
...draftCustomThemes,
|
|
||||||
[draftActiveThemeId]: {
|
[draftActiveThemeId]: {
|
||||||
...draftCustomThemes[draftActiveThemeId],
|
...draftCustomThemes[draftActiveThemeId],
|
||||||
tokens: {
|
tokens: {
|
||||||
@@ -730,17 +638,13 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
tagColours: {
|
tagColours: {
|
||||||
...draftCustomThemes[draftActiveThemeId].tokens.tagColours,
|
...draftCustomThemes[draftActiveThemeId].tokens.tagColours,
|
||||||
[category]:
|
[category]:
|
||||||
defaultThemeTagColoursFor (selectedTheme.baseTheme)[category],
|
defaultThemeTagColoursFor (
|
||||||
},
|
selectedTheme.baseTheme)[category] } } } })
|
||||||
},
|
},
|
||||||
},
|
onThemeSubmit: handleThemeSubmit,
|
||||||
},
|
settingsBaseErrors,
|
||||||
)
|
settingsFieldErrors }
|
||||||
},
|
: null
|
||||||
onThemeSubmit: handleThemeSubmit,
|
|
||||||
settingsBaseErrors,
|
|
||||||
settingsFieldErrors,
|
|
||||||
} : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
@@ -752,15 +656,40 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
|
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
|
||||||
<PageTitle>設定</PageTitle>
|
<PageTitle>設定</PageTitle>
|
||||||
<FieldError messages={settingsError ? [settingsError] : []}/>
|
<FieldError messages={settingsError ? [settingsError] : []}/>
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label="設定区分"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
|
||||||
|
{tabs.map (tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
id={tabButtonId (tab.id)}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.id}
|
||||||
|
aria-controls={tabPanelId (tab.id)}
|
||||||
|
className={cn (
|
||||||
|
'shrink-0 rounded-full border px-3 py-2 text-sm font-medium',
|
||||||
|
(activeTab === tab.id
|
||||||
|
? ['border-slate-900 bg-slate-900 text-white',
|
||||||
|
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
|
||||||
|
: ['border-border bg-background text-foreground']))}
|
||||||
|
onClick={() => setActiveTab (tab.id)}>
|
||||||
|
{tab.label}
|
||||||
|
{settingsTabErrors[tab.id] ? ' !' : ''}
|
||||||
|
</button>))}
|
||||||
|
</div>
|
||||||
{loading ? 'Loading...' : (
|
{loading ? 'Loading...' : (
|
||||||
<>
|
<div
|
||||||
{sectionProps && (
|
id={tabPanelId (activeTab)}
|
||||||
<>
|
role="tabpanel"
|
||||||
<AccountSection {...sectionProps}/>
|
aria-labelledby={tabButtonId (activeTab)}>
|
||||||
<ThemeSection {...sectionProps}/>
|
{sectionProps && activeTab === 'account' && <AccountSection {...sectionProps}/>}
|
||||||
<KeyboardSection/>
|
{sectionProps && activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
|
||||||
</>)}
|
{activeTab === 'keyboard' && (
|
||||||
</>)}
|
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:flex md:justify-center md:px-4">
|
<div className="hidden md:flex md:justify-center md:px-4">
|
||||||
@@ -781,12 +710,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
tabIndex={activeTab === tab.id ? 0 : -1}
|
tabIndex={activeTab === tab.id ? 0 : -1}
|
||||||
className={cn (
|
className={cn (
|
||||||
'rounded-xl border px-4 py-3 text-left text-sm font-medium',
|
'rounded-xl border px-4 py-3 text-left text-sm font-medium',
|
||||||
activeTab === tab.id
|
(activeTab === tab.id
|
||||||
? ['border-slate-900 bg-slate-900 text-white',
|
? ['border-slate-900 bg-slate-900 text-white',
|
||||||
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
|
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
|
||||||
: ['border-slate-300 bg-white text-slate-700',
|
: ['border-slate-300 bg-white text-slate-700',
|
||||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'],
|
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']))}
|
||||||
)}
|
|
||||||
onClick={() => setActiveTab (tab.id)}
|
onClick={() => setActiveTab (tab.id)}
|
||||||
onKeyDown={handleTabKeyDown}>
|
onKeyDown={handleTabKeyDown}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -806,7 +734,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
<>
|
<>
|
||||||
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
|
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
|
||||||
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
|
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
|
||||||
{activeTab === 'keyboard' && <KeyboardSection/>}
|
{activeTab === 'keyboard' && (
|
||||||
|
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
|
||||||
</>)}
|
</>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
@@ -826,4 +755,5 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default SettingPage
|
export default SettingPage
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする