コミットを比較

...

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.
- Do not normalize TSX to common Prettier-style React formatting unless
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.
- Put two blank lines before and after top-level `const` function
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
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 `=>`.
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 and other associative-array-style braces, do not place
the closing brace at the beginning of a line. Function, lambda, callback, and
block closing braces are exempt and should stay on their own line when that
fits the local style.
- 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.
- When writing braces on a single line in TypeScript or TSX JavaScript
context, put exactly one space inside the braces, as in `{ value }` or
`{ 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
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.
- Keep continuation indentation aligned with the 4-space logical indentation
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
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
+15 -12
ファイルの表示
@@ -18,6 +18,7 @@ import {
getClientThemeMode,
seedClientThemeMode,
} from '@/lib/settings'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
@@ -185,19 +186,21 @@ const App: FC = () => {
{import.meta.env.DEV && <DevModeWatermark/>}
<BrowserRouter>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<KeyboardShortcutsProvider>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<Toaster/>
</DialogueProvider>
<Toaster/>
</DialogueProvider>
</KeyboardShortcutsProvider>
</BrowserRouter>
</>)
}
+13 -1
ファイルの表示
@@ -7,7 +7,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
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'
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
@@ -25,6 +25,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
state,
onMouseEnter,
onTouchStart,
onKeyDown,
onClick,
cancelOnError = false,
...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 (
<a ref={ref}
href={typeof to === 'string' ? to : createPath (to)}
onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart}
onKeyDown={handleKeyDown}
onClick={handleClick}
className={cn ('cursor-pointer', className)}
{...rest}/>)
+1
ファイルの表示
@@ -105,6 +105,7 @@ const TagSearch: FC = () => {
return (
<div className="relative w-full">
<input type="text"
data-shortcut-focus-search="true"
placeholder="タグ検索..."
value={search}
onChange={whenChanged}
+5 -5
ファイルの表示
@@ -249,16 +249,16 @@ const TopNav: FC<Props> = ({ user }) => {
<TopNavUser user={user}/>
<a href="#"
className="md:hidden ml-auto pr-4
<button
type="button"
className="md:hidden ml-auto border-0 bg-transparent pr-4
text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
onClick={ev => {
ev.preventDefault ()
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
{menuOpen ? '×' : 'Menu'}
</a>
</button>
</nav>
<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 min-w-0 gap-4">
{tabs.map ((tab, i) => (
<a key={i}
href="#"
className={cn (i === current && 'font-bold')}
onClick={ev => {
ev.preventDefault ()
<button
key={i}
type="button"
className={cn (
'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)
}}>
{tab.props.name}
</a>))}
</button>))}
</div>
{rightAddon && (
<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 { apiGet, apiPatch } from '@/lib/api'
import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts'
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
import type { KeyBinding, ShortcutActionId } from '@/lib/keyboardShortcuts'
export type UserSettings = {
theme: 'system' | 'light' | 'dark'
@@ -61,6 +63,11 @@ type ClientListSettings = {
limit?: ClientListLimit
order?: string }
export type ClientKeyboardSettings = {
enabled?: boolean
bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>>
}
export type ClientAppearanceSettings = {
activeThemeId?: ActiveThemeId
customThemes?: Record<string, CustomClientTheme>
@@ -80,6 +87,7 @@ export const DEFAULT_USER_SETTINGS: UserSettings = {
// Browser-local settings only. These are device or screen specific.
export type ClientSettings = {
appearance?: ClientAppearanceSettings
keyboard?: ClientKeyboardSettings
panes?: Record<string, ClientPaneSettings>
lists?: Partial<Record<ClientListKey, ClientListSettings>>
theatre?: {
@@ -212,6 +220,15 @@ const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY =
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> => {
const raw = await apiGet<{
theme: UserSettings['theme']
@@ -286,6 +303,118 @@ const validListLimit = (value: unknown): ClientListLimit | 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 =>
/^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null
@@ -567,7 +696,7 @@ export const createCustomThemeFromBase = (
baseTheme: BuiltinThemeId,
tokens?: ThemeTokens,
): CustomClientTheme => {
const id = `custom:${ crypto.randomUUID () }` as ThemeId
const id = createCustomThemeId ()
return {
id,
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,
setClientListSettings,
} from '@/lib/settings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchWikiPageByTitle } from '@/lib/wiki'
import type { FC } from 'react'
@@ -68,6 +69,12 @@ const PostListPage: FC = () => {
setClientListSettings ('postList', { 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 params = new URLSearchParams (location.search)
params.set ('limit', String (next.limit ?? limit))
@@ -75,6 +82,23 @@ const PostListPage: FC = () => {
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 (() => {
scroll (0, 0)
+25
ファイルの表示
@@ -24,6 +24,7 @@ import {
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
@@ -171,11 +172,34 @@ const PostSearchPage: FC = () => {
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) => {
e.preventDefault ()
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',
url: 'asc',
original_created_at: 'desc',
@@ -214,6 +238,7 @@ const PostSearchPage: FC = () => {
{({ invalid }) => (
<input
type="text"
data-shortcut-focus-search="true"
value={title}
onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)}
+25
ファイルの表示
@@ -22,6 +22,7 @@ import {
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils'
@@ -180,12 +181,35 @@ const TagListPage: FC = () => {
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',
category: 'asc',
post_count: 'desc',
created_at: 'desc',
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 (
<MainArea>
<Helmet>
@@ -218,6 +242,7 @@ const TagListPage: FC = () => {
{({ invalid }) => (
<input
type="text"
data-shortcut-focus-search="true"
value={name}
onChange={e => setName (e.target.value)}
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 { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
@@ -10,6 +8,7 @@ import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue'
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
@@ -17,37 +16,35 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import {
applyThemeSelection,
BUILTIN_THEMES,
createCustomThemeFromBase,
defaultThemeTagColoursFor,
DEFAULT_USER_SETTINGS,
deriveUserThemeModeFromSelection,
fetchUserSettings,
hasStoredClientThemeSelection,
getClientActiveThemeId,
getClientCustomThemes,
getResolvedThemeFromSelection,
setClientAppearanceThemes,
updateUserSettings,
} from '@/lib/settings'
import { applyThemeSelection,
BUILTIN_THEMES,
createCustomThemeFromBase,
defaultThemeTagColoursFor,
DEFAULT_USER_SETTINGS,
deriveUserThemeModeFromSelection,
fetchUserSettings,
hasStoredClientThemeSelection,
getClientActiveThemeId,
getClientCustomThemes,
getResolvedThemeFromSelection,
setClientAppearanceThemes,
updateUserSettings } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
import type { Category, Tag, User } from '@/types'
import type {
ActiveThemeId,
ClientTheme,
CustomClientTheme,
ThemeTagColours,
UserSettings,
} from '@/lib/settings'
import type { ActiveThemeId,
ClientTheme,
CustomClientTheme,
ThemeTagColours,
UserSettings } from '@/lib/settings'
type Props = {
user: User | null
setUser: Dispatch<SetStateAction<User | null>>
}
setUser: Dispatch<SetStateAction<User | null>> }
type UserFormField = 'name'
type SettingsFormField = 'theme'
@@ -71,19 +68,18 @@ type SharedSectionProps = {
selectedTheme: ClientTheme
draftTagColours: ThemeTagColours
onThemeChange: (themeId: ActiveThemeId) => void
onEnsureEditableTheme: () => void
onTagColourChange: (category: Category, colour: string) => void
onResetAllTagColours: () => void
onResetTagColour: (category: Category) => void
onThemeSubmit: () => void
settingsBaseErrors: string[]
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
}
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>> }
const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' },
{ id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' },
]
{ id: 'keyboard', label: 'キーボード' } ]
const sectionClassName =
'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> = {
system: 'システム設定に従う',
light: 'ライト・モード',
dark: 'ダーク・モード',
}
dark: 'ダーク・モード' }
const builtinThemeLabel: Record<'light' | 'dark', string> = {
light: 'ライト・モード',
dark: 'ダーク・モード',
}
dark: 'ダーク・モード' }
const keyboardShortcutRows = [
{
action: '投稿詳細を開く',
key: 'Enter',
scope: '一覧・検索結果',
conflict: 'なし',
},
{
action: '候補タグを選ぶ',
key: '↑ / ↓ / Enter',
scope: 'タグ入力',
conflict: 'なし',
},
{
action: '候補タグを閉じる',
key: 'Escape',
scope: 'タグ入力',
conflict: 'なし',
},
]
const previewTag = (
id: number,
@@ -140,8 +114,7 @@ const previewTag = (
hasWiki,
materialId: null,
hasDeerjikists: options?.hasDeerjikists ?? false,
children: [],
})
children: [] })
const previewTags: Record<Category, Tag> = {
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
@@ -150,15 +123,14 @@ const previewTags: Record<Category, Tag> = {
general: previewTag (4, '一般', 'general', 78, true),
material: previewTag (5, '素材', 'material', 90, false),
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 tab = new URLSearchParams (search).get ('tab')
return tabs.some (candidate => candidate.id === tab)
? tab as SettingsTab
: 'account'
return (tabs.some (candidate => candidate.id === tab)
? (tab as SettingsTab)
: 'account')
}
@@ -170,9 +142,7 @@ const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
Object.fromEntries (
CATEGORIES.flatMap (category => [
[`--tag-colour-${ category }`, tagColours[category]],
[`--tag-colour-${ category }-hover`, tagColours[category]],
]),
) as CSSProperties
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
const themeLabelById = (
@@ -187,16 +157,16 @@ const themeLabelById = (
}
const AccountSection: FC<SharedSectionProps> = ({
name,
onInheritOpen,
onNameChange,
onUserCodeOpen,
onUserSubmit,
user,
userBaseErrors,
userFieldErrors,
}) => (
const AccountSection: FC<SharedSectionProps> = (
{ name,
onInheritOpen,
onNameChange,
onUserCodeOpen,
onUserSubmit,
user,
userBaseErrors,
userFieldErrors },
) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={userBaseErrors}/>
@@ -247,19 +217,20 @@ const AccountSection: FC<SharedSectionProps> = ({
</section>)
const ThemeSection: FC<SharedSectionProps> = ({
draftActiveThemeId,
draftCustomThemes,
selectedTheme,
draftTagColours,
onResetAllTagColours,
onResetTagColour,
onTagColourChange,
onThemeChange,
onThemeSubmit,
settingsBaseErrors,
settingsFieldErrors,
}) => (
const ThemeSection: FC<SharedSectionProps> = (
{ draftActiveThemeId,
draftCustomThemes,
selectedTheme,
draftTagColours,
onEnsureEditableTheme,
onResetAllTagColours,
onResetTagColour,
onTagColourChange,
onThemeChange,
onThemeSubmit,
settingsBaseErrors,
settingsFieldErrors },
) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={settingsBaseErrors}/>
@@ -283,28 +254,10 @@ const ThemeSection: FC<SharedSectionProps> = ({
</select>)}
</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="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
<p className="text-sm text-muted-foreground">
{selectedTheme.builtin || draftActiveThemeId === 'system'
? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.'
: `現在の編輯対象: ${ selectedTheme.name }`}
</p>
</div>
<Button
type="button"
@@ -331,6 +284,8 @@ const ThemeSection: FC<SharedSectionProps> = ({
<input
type="color"
value={draftTagColours[category]}
onPointerDown={onEnsureEditableTheme}
onFocus={onEnsureEditableTheme}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
@@ -371,53 +326,12 @@ const ThemeSection: FC<SharedSectionProps> = ({
</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>
</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 location = useLocation ()
const navigate = useNavigate ()
@@ -436,24 +350,21 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const [draftCustomThemes, setDraftCustomThemes] =
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const {
baseErrors: userBaseErrors,
fieldErrors: userFieldErrors,
clearValidationErrors: clearUserValidationErrors,
applyValidationError: applyUserValidationError,
} = useValidationErrors<UserFormField> ()
const {
baseErrors: settingsBaseErrors,
fieldErrors: settingsFieldErrors,
clearValidationErrors: clearSettingsValidationErrors,
applyValidationError: applySettingsValidationError,
} = useValidationErrors<SettingsFormField> ()
const { conflictActionIds } = useKeyboardShortcutSettings ()
const { baseErrors: userBaseErrors,
fieldErrors: userFieldErrors,
clearValidationErrors: clearUserValidationErrors,
applyValidationError: applyUserValidationError } = useValidationErrors<UserFormField> ()
const { baseErrors: settingsBaseErrors,
fieldErrors: settingsFieldErrors,
clearValidationErrors: clearSettingsValidationErrors,
applyValidationError: applySettingsValidationError } =
useValidationErrors<SettingsFormField> ()
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
const selectedTheme = useMemo<ClientTheme> (
() => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes),
[draftActiveThemeId, draftCustomThemes],
)
[draftActiveThemeId, draftCustomThemes])
const draftTagColours = selectedTheme.tokens.tagColours
const setActiveTab = (tab: SettingsTab) => {
@@ -461,15 +372,15 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
params.set ('tab', tab)
navigate (
`${ location.pathname }?${ params.toString () }`,
{ replace: true },
)
{ replace: true })
}
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (() => ({
account: Boolean (userFieldErrors.name?.length),
theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: false,
}), [settingsFieldErrors.theme, userFieldErrors.name])
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
() => (
{ account: Boolean (userFieldErrors.name?.length),
theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: conflictActionIds.size > 0 }),
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name])
const applyDraftThemeSelection = (
activeThemeId: ActiveThemeId,
@@ -479,39 +390,42 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setDraftCustomThemes (customThemes)
setDraftSettings (current => ({
...current,
theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes),
}))
theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes) }))
applyThemeSelection (activeThemeId, customThemes)
}
const ensureEditableCustomTheme = (): {
activeThemeId: ActiveThemeId
customThemes: Record<string, CustomClientTheme>
} => {
customThemes: Record<string, CustomClientTheme> } => {
if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null)
{
return {
activeThemeId: draftActiveThemeId,
customThemes: draftCustomThemes,
}
return { activeThemeId: draftActiveThemeId,
customThemes: draftCustomThemes }
}
const customTheme = createCustomThemeFromBase (
selectedTheme.baseTheme,
{
...selectedTheme.tokens,
tagColours: { ...selectedTheme.tokens.tagColours },
},
)
{ ...selectedTheme.tokens,
tagColours: { ...selectedTheme.tokens.tagColours } })
const nextCustomThemes = {
...draftCustomThemes,
[customTheme.id]: customTheme,
}
[customTheme.id]: customTheme }
return {
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>) => {
@@ -612,9 +526,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
return
const customThemes = getClientCustomThemes ()
const activeThemeId = hasStoredClientThemeSelection ()
? getClientActiveThemeId ()
: data.theme
const activeThemeId = (hasStoredClientThemeSelection ()
? getClientActiveThemeId ()
: data.theme)
const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes)
setDraftSettings ({ ...data, theme: themeMode })
@@ -661,68 +575,62 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}
}, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes])
const sectionProps: SharedSectionProps | null = user ? {
user,
name,
onNameChange: setName,
onInheritOpen: () => setInheritVsbl (true),
onUserCodeOpen: () => setUserCodeVsbl (true),
onUserSubmit: handleUserSubmit,
userBaseErrors,
userFieldErrors,
draftActiveThemeId,
draftCustomThemes,
selectedTheme,
draftTagColours,
onThemeChange: themeId => {
applyDraftThemeSelection (themeId, draftCustomThemes)
},
onTagColourChange: (category, colour) => {
const editable = ensureEditableCustomTheme ()
const editableTheme = editable.customThemes[editable.activeThemeId]
const nextCustomThemes = {
...editable.customThemes,
[editable.activeThemeId]: {
...editableTheme,
tokens: {
...editableTheme.tokens,
tagColours: {
...editableTheme.tokens.tagColours,
[category]: colour,
},
},
},
}
applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes)
},
onResetAllTagColours: () => {
if (selectedTheme.builtin || draftActiveThemeId === 'system')
return
const sectionProps: SharedSectionProps | null =
user
? { user,
name,
onNameChange: setName,
onInheritOpen: () => setInheritVsbl (true),
onUserCodeOpen: () => setUserCodeVsbl (true),
onUserSubmit: handleUserSubmit,
userBaseErrors,
userFieldErrors,
draftActiveThemeId,
draftCustomThemes,
selectedTheme,
draftTagColours,
onThemeChange: themeId => {
applyDraftThemeSelection (themeId, draftCustomThemes)
},
onEnsureEditableTheme: () => {
handleEnsureEditableTheme ()
},
onTagColourChange: (category, colour) => {
const editable = handleEnsureEditableTheme ()
const editableTheme = editable.customThemes[editable.activeThemeId]
const nextCustomThemes = {
...editable.customThemes,
[editable.activeThemeId]: {
...editableTheme,
tokens: {
...editableTheme.tokens,
tagColours: {
...editableTheme.tokens.tagColours,
[category]: colour } } } }
applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes)
},
onResetAllTagColours: () => {
if (selectedTheme.builtin || draftActiveThemeId === 'system')
return
applyDraftThemeSelection (
draftActiveThemeId,
{
...draftCustomThemes,
[draftActiveThemeId]: {
...draftCustomThemes[draftActiveThemeId],
tokens: {
...draftCustomThemes[draftActiveThemeId].tokens,
tagColours: {
...defaultThemeTagColoursFor (selectedTheme.baseTheme),
},
},
},
},
)
},
onResetTagColour: category => {
if (selectedTheme.builtin || draftActiveThemeId === 'system')
return
applyDraftThemeSelection (
draftActiveThemeId,
{
...draftCustomThemes,
[draftActiveThemeId]: {
...draftCustomThemes[draftActiveThemeId],
tokens: {
...draftCustomThemes[draftActiveThemeId].tokens,
tagColours: {
...defaultThemeTagColoursFor (selectedTheme.baseTheme) } } } })
},
onResetTagColour: category => {
if (selectedTheme.builtin || draftActiveThemeId === 'system')
return
applyDraftThemeSelection (
draftActiveThemeId,
{
...draftCustomThemes,
applyDraftThemeSelection (
draftActiveThemeId,
{ ...draftCustomThemes,
[draftActiveThemeId]: {
...draftCustomThemes[draftActiveThemeId],
tokens: {
@@ -730,17 +638,13 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
tagColours: {
...draftCustomThemes[draftActiveThemeId].tokens.tagColours,
[category]:
defaultThemeTagColoursFor (selectedTheme.baseTheme)[category],
},
},
},
},
)
},
onThemeSubmit: handleThemeSubmit,
settingsBaseErrors,
settingsFieldErrors,
} : null
defaultThemeTagColoursFor (
selectedTheme.baseTheme)[category] } } } })
},
onThemeSubmit: handleThemeSubmit,
settingsBaseErrors,
settingsFieldErrors }
: null
return (
<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">
<PageTitle></PageTitle>
<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...' : (
<>
{sectionProps && (
<>
<AccountSection {...sectionProps}/>
<ThemeSection {...sectionProps}/>
<KeyboardSection/>
</>)}
</>)}
<div
id={tabPanelId (activeTab)}
role="tabpanel"
aria-labelledby={tabButtonId (activeTab)}>
{sectionProps && activeTab === 'account' && <AccountSection {...sectionProps}/>}
{sectionProps && activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
</div>)}
</div>
<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}
className={cn (
'rounded-xl border px-4 py-3 text-left 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-slate-300 bg-white text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'],
)}
(activeTab === tab.id
? ['border-slate-900 bg-slate-900 text-white',
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
: ['border-slate-300 bg-white text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']))}
onClick={() => setActiveTab (tab.id)}
onKeyDown={handleTabKeyDown}>
{tab.label}
@@ -806,7 +734,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<>
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
{activeTab === 'keyboard' && <KeyboardSection/>}
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
</>)}
</div>)}
</div>
@@ -826,4 +755,5 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
</MainArea>)
}
export default SettingPage