コミットを比較

...

5 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 4e3fb6bef7 #34 2026-07-05 03:43:37 +09:00
みてるぞ 3a9ac8af32 #34 2026-07-05 03:27:58 +09:00
みてるぞ 7bc9ee1e68 #34 2026-07-05 03:19:58 +09:00
みてるぞ 95b4db1fc2 #34 2026-07-05 02:59:48 +09:00
みてるぞ 4117bdd31e #34 2026-07-05 02:40:19 +09:00
16個のファイルの変更1470行の追加303行の削除
+23 -5
ファイルの表示
@@ -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 }`.
+29
ファイルの表示
@@ -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>
</>) </>)
} }
+13 -1
ファイルの表示
@@ -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}/>)
+1
ファイルの表示
@@ -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}
+5 -5
ファイルの表示
@@ -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}>
+10 -6
ファイルの表示
@@ -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">
+251
ファイルの表示
@@ -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
+77
ファイルの表示
@@ -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
+319
ファイルの表示
@@ -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),
+320
ファイルの表示
@@ -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,
}
+24
ファイルの表示
@@ -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)
+25
ファイルの表示
@@ -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)}/>)}
+25
ファイルの表示
@@ -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)}/>)}
+203 -273
ファイルの表示
@@ -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_titleauto_fetch_thumbnailwiki_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