このコミットが含まれているのは:
2026-07-05 20:00:58 +09:00
コミット 021d13a262
10個のファイルの変更647行の追加480行の削除
+38 -30
ファイルの表示
@@ -266,38 +266,45 @@ const value =
《当用漢字による書きかえ》; prefer original forms such as `編輯`. 《当用漢字による書きかえ》; prefer original forms such as `編輯`.
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`. - For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
### Frontend TSX style ### Frontend TypeScript and TSX style
- Preserve the local TSX formatting style. - The delimiter-placement and line-breaking rules in this section apply to
both plain TypeScript `.ts` and TSX `.tsx`, unless a bullet explicitly says
it is JSX- or React-specific.
- Preserve the local TypeScript and 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 - Treat TypeScript and TSX formatting rules as hard constraints, not
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` preferences. Before finishing a TypeScript or TSX edit, inspect the edited
placement and fix violations instead of relying on formatter defaults. hunks for closing `)`, `]`, and `}` placement and fix violations instead of
- After every TSX edit, perform a style-only self-review of the edited hunks relying on formatter defaults.
before running verification or reporting completion. The task is not complete - After every TypeScript or TSX edit, perform a style-only self-review of the
while any edited TSX hunk violates these local formatting rules. edited hunks before running verification or reporting completion. The task is
- The TSX self-review must classify every edited leading or trailing `)`, `]`, not complete while any edited TypeScript or TSX hunk violates these local
and `}` by syntax role before deciding whether it is valid. Do not apply a formatting rules.
rule by glyph alone. A closing `)` for a function parameter list is different - The TypeScript/TSX self-review must classify every edited leading or trailing
from a closing `)` for a function call. A closing `}` for a block is different `)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not
from a closing `}` for an object, type literal, import list, or destructuring apply a rule by glyph alone. A closing `)` for a function parameter list is
pattern. different from a closing `)` for a function call. A closing `}` for a block
- The TSX self-review must confirm there are no common Prettier-style React is different from a closing `}` for an object, type literal, import list, or
component declarations with a multi-line destructured parameter. destructuring pattern.
- The TSX self-review must confirm multi-line function declaration parameter - The TSX-specific self-review must confirm there are no common Prettier-style
`)` placement follows the detailed parameter-list rules below. React component declarations with a multi-line destructured parameter.
- The TSX self-review must confirm call-expression `)` is never at the - The TypeScript/TSX self-review must confirm multi-line function declaration
parameter `)` placement follows the detailed parameter-list rules below.
- The TypeScript/TSX self-review must confirm call-expression `)` is never at
the beginning of a line.
- The TypeScript/TSX self-review must confirm
object/type/import/destructuring `}` is not at the beginning of a line.
- The TypeScript/TSX self-review must confirm multi-line
function/lambda/callback/block `}` is on its own line and never at the end
of the previous line.
- The TypeScript/TSX self-review must confirm array `]` is not at the
beginning of a line. beginning of a line.
- The TSX self-review must confirm object/type/import/destructuring `}` is not - The TSX-specific self-review must confirm JSX closing markers and closing
at the beginning of a line. parentheses keep the surrounding compact style.
- The TSX self-review must confirm multi-line function/lambda/callback/block - The TypeScript/TSX self-review must confirm leading indentation follows
`}` is on its own line and never at the end of the previous line. 4-space logical indentation with tabs only as leading 8-space compression.
- The TSX self-review must confirm array `]` is not at the beginning of a line.
- The TSX self-review must confirm JSX closing markers and closing parentheses
keep the surrounding compact style.
- The TSX self-review must confirm leading indentation follows 4-space logical
indentation with tabs only as leading 8-space compression.
- 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.
@@ -699,10 +706,11 @@ Good:
Rule: JavaScript object braces on one line get one inner space. JSX expression Rule: JavaScript object braces on one line get one inner space. JSX expression
braces do not get inner spaces. braces do not get inner spaces.
#### Final TSX self-review checklist #### Final TypeScript/TSX self-review checklist
Before reporting completion after a TypeScript or TSX edit, check the edited Before reporting completion after a TypeScript or TSX edit, check the edited
hunks line by line: hunks line by line. Unless a step explicitly mentions JSX, it applies equally
to `.ts` and `.tsx`:
1. No import/export/type/object/destructuring `}` appears alone at the beginning 1. No import/export/type/object/destructuring `}` appears alone at the beginning
of a line. of a line.
+36 -28
ファイルの表示
@@ -122,37 +122,44 @@ pass or the remaining failure is clearly blocked.
if the class cannot be statically detected. if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval. - Do not introduce new UI libraries or production dependencies without approval.
## TSX formatting ## TypeScript and TSX formatting
- The delimiter-placement and line-breaking rules in this section apply to
both plain TypeScript `.ts` and TSX `.tsx`, unless a bullet explicitly says
it is JSX- or React-specific.
- 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 - Treat TypeScript and TSX formatting rules as hard constraints, not
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` preferences. Before finishing a TypeScript or TSX edit, inspect the edited
placement and fix violations instead of relying on formatter defaults. hunks for closing `)`, `]`, and `}` placement and fix violations instead of
- After every TSX edit, perform a style-only self-review of the edited hunks relying on formatter defaults.
before running verification or reporting completion. The task is not complete - After every TypeScript or TSX edit, perform a style-only self-review of the
while any edited TSX hunk violates these local formatting rules. edited hunks before running verification or reporting completion. The task is
- The TSX self-review must classify every edited leading or trailing `)`, `]`, not complete while any edited TypeScript or TSX hunk violates these local
and `}` by syntax role before deciding whether it is valid. Do not apply a formatting rules.
rule by glyph alone. A closing `)` for a function parameter list is different - The TypeScript/TSX self-review must classify every edited leading or trailing
from a closing `)` for a function call. A closing `}` for a block is different `)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not
from a closing `}` for an object, type literal, import list, or destructuring apply a rule by glyph alone. A closing `)` for a function parameter list is
pattern. different from a closing `)` for a function call. A closing `}` for a block
- The TSX self-review must confirm there are no common Prettier-style React is different from a closing `}` for an object, type literal, import list, or
component declarations with a multi-line destructured parameter. destructuring pattern.
- The TSX self-review must confirm multi-line function declaration parameter - The TSX-specific self-review must confirm there are no common Prettier-style
`)` placement follows the detailed parameter-list rules below. React component declarations with a multi-line destructured parameter.
- The TSX self-review must confirm call-expression `)` is never at the - The TypeScript/TSX self-review must confirm multi-line function declaration
parameter `)` placement follows the detailed parameter-list rules below.
- The TypeScript/TSX self-review must confirm call-expression `)` is never at
the beginning of a line.
- The TypeScript/TSX self-review must confirm
object/type/import/destructuring `}` is not at the beginning of a line.
- The TypeScript/TSX self-review must confirm multi-line
function/lambda/callback/block `}` is on its own line and never at the end
of the previous line.
- The TypeScript/TSX self-review must confirm array `]` is not at the
beginning of a line. beginning of a line.
- The TSX self-review must confirm object/type/import/destructuring `}` is not - The TSX-specific self-review must confirm JSX closing markers and closing
at the beginning of a line. parentheses keep the surrounding compact style.
- The TSX self-review must confirm multi-line function/lambda/callback/block - The TypeScript/TSX self-review must confirm leading indentation follows
`}` is on its own line and never at the end of the previous line. 4-space logical indentation with tabs only as leading 8-space compression.
- The TSX self-review must confirm array `]` is not at the beginning of a line.
- The TSX self-review must confirm JSX closing markers and closing parentheses
keep the surrounding compact style.
- The TSX self-review must confirm leading indentation follows 4-space logical
indentation with tabs only as leading 8-space compression.
- 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.
@@ -215,7 +222,8 @@ pass or the remaining failure is clearly blocked.
### Delimiter decision table ### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit. more authoritative than formatter habit. Unless a subsection explicitly
mentions JSX, it applies equally to `.ts` and `.tsx`.
#### Import and export named bindings #### Import and export named bindings
+27 -28
ファイルの表示
@@ -208,12 +208,12 @@ const TopNav: FC<Props> = ({ user }) => {
return ( return (
<> <>
<nav className="px-3 flex justify-between items-center w-full <nav className="top-nav-root px-3 flex justify-between items-center w-full
border-b top-nav-themed"> border-b">
<div className="flex items-center gap-2 h-12"> <div className="flex items-center gap-2 h-12">
<PrefetchLink <PrefetchLink
to="/posts" to="/posts"
className="mx-4 text-xl font-bold" className="top-nav-link mx-4 text-xl font-bold"
onClick={() => { onClick={() => {
scroll (0, 0) scroll (0, 0)
}}> }}>
@@ -223,7 +223,7 @@ const TopNav: FC<Props> = ({ user }) => {
<div ref={navRef} className="relative hidden md:flex h-12 items-center"> <div ref={navRef} className="relative hidden md:flex h-12 items-center">
<div aria-hidden <div aria-hidden
className={cn ('absolute inset-y-0 h-12', className={cn ('absolute inset-y-0 h-12',
'top-nav-themed-active', 'top-nav-highlight',
highlightTransitionClass)} highlightTransitionClass)}
style={{ width: hl.width, style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`, transform: `translateX(${ hl.left }px)`,
@@ -245,8 +245,8 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el itemsRef.current[i] = el
}} }}
className={cn ('relative z-10 flex h-full items-center px-5', className={cn ('top-nav-link relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'top-nav-themed-active font-bold')}> (i === openItemIdx) && 'font-bold')}>
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
</motion.div>))} </motion.div>))}
@@ -260,8 +260,8 @@ const TopNav: FC<Props> = ({ user }) => {
setMoreVsbl (true) setMoreVsbl (true)
measure (-1) measure (-1)
}} }}
className={cn ('relative z-10 flex h-full items-center px-5', className={cn ('top-nav-link relative z-10 flex h-full items-center px-5',
(openItemIdx < 0 || moreVsbl) && 'top-nav-themed-active font-bold')}> (openItemIdx < 0 || moreVsbl) && 'font-bold')}>
&raquo; &raquo;
</PrefetchLink> </PrefetchLink>
</div> </div>
@@ -271,7 +271,7 @@ const TopNav: FC<Props> = ({ user }) => {
<button <button
type="button" type="button"
className="md:hidden ml-auto border-0 bg-transparent pr-4" className="top-nav-link md:hidden ml-auto border-0 bg-transparent pr-4"
onClick={() => { onClick={() => {
setMenuOpen (!(menuOpen)) setMenuOpen (!(menuOpen))
}}> }}>
@@ -284,8 +284,7 @@ const TopNav: FC<Props> = ({ user }) => {
key="submenu-shell" key="submenu-shell"
{...(animationsOff ? { } {...(animationsOff ? { }
: { layout: true })} : { layout: true })}
className="relative z-20 hidden md:block overflow-hidden className="relative z-20 hidden md:block overflow-hidden top-nav-submenu"
top-nav-themed-submenu"
animate={{ height: submenuHeight }} animate={{ height: submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
@@ -346,7 +345,7 @@ const TopNav: FC<Props> = ({ user }) => {
to={subItem.to} to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined} target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)} onClick={() => setMoreVsbl (false)}
className="h-full flex items-center px-3"> className="top-nav-link h-full flex items-center px-3">
{subItem.name} {subItem.name}
</PrefetchLink> </PrefetchLink>
</motion.div>)))} </motion.div>)))}
@@ -373,7 +372,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={item.to.slice (0, 2) === '//' target={item.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="h-full flex items-center px-3"> className="top-nav-link h-full flex items-center px-3">
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
</div>)))} </div>)))}
@@ -412,7 +411,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={item.to.slice (0, 2) === '//' target={item.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="h-full flex items-center px-3"> className="top-nav-link h-full flex items-center px-3">
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
</motion.div>)))} </motion.div>)))}
@@ -426,16 +425,16 @@ const TopNav: FC<Props> = ({ user }) => {
? ( ? (
menuOpen && ( menuOpen && (
<div <div
className={cn ('top-nav-themed top-nav-themed-submenu flex flex-col md:hidden', className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'items-start')}> 'items-start')}>
<Separator/> <Separator/>
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
<Fragment key={i}> <Fragment key={i}>
<PrefetchLink <PrefetchLink
to={i === openItemIdx ? item.to : '#'} to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-link w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx) ((i === openItemIdx)
&& 'top-nav-themed-active font-bold'))} && 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => { onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx) if (i !== openItemIdx)
{ {
@@ -447,7 +446,7 @@ const TopNav: FC<Props> = ({ user }) => {
</PrefetchLink> </PrefetchLink>
{i === openItemIdx && ( {i === openItemIdx && (
<div className="top-nav-themed-submenu w-full"> <div className="top-nav-submenu w-full">
{item.subMenu {item.subMenu
.filter (subItem => subItem.visible ?? true) .filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => ( .map ((subItem, j) => (
@@ -463,7 +462,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={subItem.to.slice (0, 2) === '//' target={subItem.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="w-full min-h-[36px] flex items-center pl-12"> className="top-nav-link w-full min-h-[36px] flex items-center pl-12">
{subItem.name} {subItem.name}
</PrefetchLink>)))} </PrefetchLink>)))}
</div>)} </div>)}
@@ -473,9 +472,9 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el itemsRef.current[visibleMenu.length] = el
}} }}
className={cn ('w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-link w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0) ((openItemIdx < 0)
&& 'top-nav-themed-active font-bold'))}> && 'top-nav-mobile-active font-bold'))}>
&raquo; &raquo;
</PrefetchLink> </PrefetchLink>
<TopNavUser user={user} sp/> <TopNavUser user={user} sp/>
@@ -486,7 +485,7 @@ const TopNav: FC<Props> = ({ user }) => {
{menuOpen && ( {menuOpen && (
<motion.div <motion.div
key="spmenu" key="spmenu"
className={cn ('top-nav-themed top-nav-themed-submenu flex flex-col md:hidden', className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'items-start')} 'items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 }, height: 0 },
@@ -501,9 +500,9 @@ const TopNav: FC<Props> = ({ user }) => {
<Fragment key={i}> <Fragment key={i}>
<PrefetchLink <PrefetchLink
to={i === openItemIdx ? item.to : '#'} to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-link w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx) ((i === openItemIdx)
&& 'top-nav-themed-active font-bold'))} && 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => { onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx) if (i !== openItemIdx)
{ {
@@ -518,7 +517,7 @@ const TopNav: FC<Props> = ({ user }) => {
{i === openItemIdx && ( {i === openItemIdx && (
<motion.div <motion.div
key={`sp-sub-${ i }`} key={`sp-sub-${ i }`}
className="top-nav-themed-submenu w-full" className="top-nav-submenu w-full"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0, height: 0,
opacity: 0 }, opacity: 0 },
@@ -544,7 +543,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={subItem.to.slice (0, 2) === '//' target={subItem.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="w-full min-h-[36px] flex items-center pl-12"> className="top-nav-link w-full min-h-[36px] flex items-center pl-12">
{subItem.name} {subItem.name}
</PrefetchLink>)))} </PrefetchLink>)))}
</motion.div>)} </motion.div>)}
@@ -555,9 +554,9 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el itemsRef.current[visibleMenu.length] = el
}} }}
className={cn ('w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-link w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0) ((openItemIdx < 0)
&& 'top-nav-themed-active font-bold'))}> && 'top-nav-mobile-active font-bold'))}>
&raquo; &raquo;
</PrefetchLink> </PrefetchLink>
<TopNavUser user={user} sp/> <TopNavUser user={user} sp/>
+17 -2
ファイルの表示
@@ -15,7 +15,11 @@ import type { ClientAnimationMode,
ClientThumbnailMode } from '@/lib/settings' ClientThumbnailMode } from '@/lib/settings'
type Props = { type Props = {
sectionClassName: string } sectionClassName: string
onDirtyStateChange?: (
dirty: boolean,
discard: () => void,
) => void }
type SegmentedOption<T extends string> = { type SegmentedOption<T extends string> = {
value: T value: T
@@ -95,7 +99,10 @@ const SettingBlock: FC<SettingBlockProps> = (
</div>) </div>)
const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => { const BehaviourSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
const [savedSettings, setSavedSettings] = const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ()) useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
const [draftSettings, setDraftSettings] = const [draftSettings, setDraftSettings] =
@@ -129,6 +136,14 @@ const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => {
setDraftSettings (savedSettings) setDraftSettings (savedSettings)
} }
useEffect (() => {
onDirtyStateChange?.(hasUnsavedChanges, handleDiscard)
return () => {
onDirtyStateChange?.(false, () => { })
}
}, [handleDiscard, hasUnsavedChanges, onDirtyStateChange])
return ( return (
<section className={sectionClassName}> <section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
+16 -1
ファイルの表示
@@ -19,10 +19,17 @@ import type { FC } from 'react'
type Props = { type Props = {
sectionClassName: string sectionClassName: string
onDirtyStateChange?: (
dirty: boolean,
discard: () => void,
) => void
} }
const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => { const KeyboardSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
const { const {
keyboardSettings, keyboardSettings,
saveKeyboardSettings, saveKeyboardSettings,
@@ -127,6 +134,14 @@ const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
setCapturingActionId (null) setCapturingActionId (null)
} }
useEffect (() => {
onDirtyStateChange?.(hasUnsavedChanges, handleDiscard)
return () => {
onDirtyStateChange?.(false, () => { })
}
}, [handleDiscard, hasUnsavedChanges, onDirtyStateChange])
return ( return (
<section className={sectionClassName}> <section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
+38 -37
ファイルの表示
@@ -34,15 +34,12 @@
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%; --ring: 222.2 84% 4.9%;
--theme-link: 221.2 83.2% 53.3%; --theme-link: 221.2 83.2% 53.3%;
--top-nav-background: 48 96% 89%; --top-nav-root-bg-mobile: #fef08a;
--top-nav-foreground: 20 14.3% 4.1%; --top-nav-root-bg-desktop: #fefce8;
--top-nav-border: 45 93% 77%; --top-nav-highlight-bg: #fef08a;
--top-nav-link: 334 84% 57%; --top-nav-submenu-bg: #fef08a;
--top-nav-link-hover: 334 78% 69%; --top-nav-mobile-active-bg: #fefce8;
--top-nav-active-background: 48 96% 89%; --top-nav-link: #db2777;
--top-nav-active-foreground: 20 14.3% 4.1%;
--top-nav-submenu-background: 48 100% 96%;
--top-nav-submenu-foreground: 20 14.3% 4.1%;
--tag-colour-deerjikist: #9f1239; --tag-colour-deerjikist: #9f1239;
--tag-colour-deerjikist-hover: #b62a51; --tag-colour-deerjikist-hover: #b62a51;
@@ -87,15 +84,12 @@
--border: 217.2 32.6% 17.5%; --border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%; --ring: 212.7 26.8% 83.9%;
--top-nav-background: 0 49% 20%; --top-nav-root-bg-mobile: #230505;
--top-nav-foreground: 345 100% 93%; --top-nav-root-bg-desktop: #230505;
--top-nav-border: 0 38% 28%; --top-nav-highlight-bg: #450a0a;
--top-nav-link: 335 82% 80%; --top-nav-submenu-bg: #450a0a;
--top-nav-link-hover: 341 100% 92%; --top-nav-mobile-active-bg: #450a0a;
--top-nav-active-background: 0 38% 28%; --top-nav-link: #f9a8d4;
--top-nav-active-foreground: 345 100% 93%;
--top-nav-submenu-background: 0 49% 20%;
--top-nav-submenu-foreground: 345 100% 93%;
} }
body body
@@ -215,41 +209,48 @@ body
color: var(--tag-link-hover-colour) !important; color: var(--tag-link-hover-colour) !important;
} }
.top-nav-themed .top-nav-root
{ {
background: hsl(var(--top-nav-background)); background: var(--top-nav-root-bg-mobile);
color: hsl(var(--top-nav-foreground)); border-color: transparent;
border-color: hsl(var(--top-nav-border));
} }
.top-nav-themed a, @media (min-width: 768px)
.top-nav-themed button
{ {
color: hsl(var(--top-nav-link)); .top-nav-root
{
background: var(--top-nav-root-bg-desktop);
}
} }
.top-nav-themed a:hover, .top-nav-link
.top-nav-themed button:hover
{ {
color: hsl(var(--top-nav-link-hover)); color: var(--top-nav-link);
} }
.top-nav-themed-active .top-nav-link:hover
{ {
background: hsl(var(--top-nav-active-background)); color: color-mix(in oklab, var(--top-nav-link), white 18%);
color: hsl(var(--top-nav-active-foreground));
} }
.top-nav-themed a.top-nav-themed-active, .top-nav-highlight
.top-nav-themed button.top-nav-themed-active
{ {
color: hsl(var(--top-nav-active-foreground)); background: var(--top-nav-highlight-bg);
} }
.top-nav-themed-submenu .top-nav-submenu
{ {
background: hsl(var(--top-nav-submenu-background)); background: var(--top-nav-submenu-bg);
color: hsl(var(--top-nav-submenu-foreground)); }
.top-nav-mobile-menu
{
background: var(--top-nav-root-bg-mobile);
}
.top-nav-mobile-active
{
background: var(--top-nav-mobile-active-bg);
} }
.tag-marquee__animated .tag-marquee__animated
+11 -11
ファイルの表示
@@ -17,9 +17,9 @@ export type ShortcutActionId =
| 'pagination.next' | 'pagination.next'
| 'pagination.previous' | 'pagination.previous'
| 'settings.account' | 'settings.account'
| 'settings.behavior'
| 'settings.theme' | 'settings.theme'
| 'settings.keyboard' | 'settings.keyboard'
| 'settings.behavior'
export type KeyBinding = { export type KeyBinding = {
key: string key: string
@@ -102,20 +102,20 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
defaultBinding: { key: '1' }, defaultBinding: { key: '1' },
}, },
{ {
id: 'settings.theme', id: 'settings.behavior',
label: '設定: テーマ', label: '設定: 動作',
scope: 'settings', scope: 'settings',
defaultBinding: { key: '2' }, defaultBinding: { key: '2' },
}, },
{ {
id: 'settings.keyboard', id: 'settings.theme',
label: '設定: キーボード', label: '設定: テーマ',
scope: 'settings', scope: 'settings',
defaultBinding: { key: '3' }, defaultBinding: { key: '3' },
}, },
{ {
id: 'settings.behavior', id: 'settings.keyboard',
label: '設定: 動作', label: '設定: キーボード',
scope: 'settings', scope: 'settings',
defaultBinding: { key: '4' }, defaultBinding: { key: '4' },
}, },
@@ -137,9 +137,9 @@ export const SHORTCUT_DEFINITIONS_BY_ID =
'pagination.next': SHORTCUT_DEFINITIONS[6], 'pagination.next': SHORTCUT_DEFINITIONS[6],
'pagination.previous': SHORTCUT_DEFINITIONS[7], 'pagination.previous': SHORTCUT_DEFINITIONS[7],
'settings.account': SHORTCUT_DEFINITIONS[8], 'settings.account': SHORTCUT_DEFINITIONS[8],
'settings.theme': SHORTCUT_DEFINITIONS[9], 'settings.behavior': SHORTCUT_DEFINITIONS[9],
'settings.keyboard': SHORTCUT_DEFINITIONS[10], 'settings.theme': SHORTCUT_DEFINITIONS[10],
'settings.behavior': SHORTCUT_DEFINITIONS[11], 'settings.keyboard': SHORTCUT_DEFINITIONS[11],
}, },
) )
@@ -159,9 +159,9 @@ export const DEFAULT_KEY_BINDINGS =
'pagination.next': null, 'pagination.next': null,
'pagination.previous': null, 'pagination.previous': null,
'settings.account': null, 'settings.account': null,
'settings.behavior': null,
'settings.theme': null, 'settings.theme': null,
'settings.keyboard': null, 'settings.keyboard': null,
'settings.behavior': null,
}, },
) )
+91 -97
ファイルの表示
@@ -43,17 +43,13 @@ export type ThemeTokens = {
input: string input: string
ring: string ring: string
link: string link: string
topNavBackground: string topNavRootBackgroundMobile: string
topNavForeground: string topNavRootBackgroundDesktop: string
topNavBorder: string topNavHighlightBackground: string
topNavLink: string topNavSubmenuBackground: string
topNavLinkHover: string topNavMobileActiveBackground: string
topNavActiveBackground: string topNavLink: string
topNavActiveForeground: string tagColours: ThemeTagColours }
topNavSubmenuBackground: string
topNavSubmenuForeground: string
tagColours: ThemeTagColours
}
export type UserThemeSlot = { export type UserThemeSlot = {
baseTheme: BuiltinThemeId baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo slotNo: UserThemeSlotNo
@@ -98,8 +94,7 @@ type ClientListSettings = {
export type ClientKeyboardSettings = { export type ClientKeyboardSettings = {
enabled?: boolean enabled?: boolean
bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>> bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>> }
}
export type ClientAppearanceSettings = { export type ClientAppearanceSettings = {
activeThemeSlot?: ThemeSlotSelection activeThemeSlot?: ThemeSlotSelection
@@ -114,8 +109,7 @@ export type ClientBehaviorSettings = {
linkPreload?: ClientLinkPreloadMode linkPreload?: ClientLinkPreloadMode
tagRelationDisplay?: ClientTagRelationDisplayMode tagRelationDisplay?: ClientTagRelationDisplayMode
embedAutoLoad?: ClientEmbedAutoLoadMode embedAutoLoad?: ClientEmbedAutoLoadMode
thumbnailMode?: ClientThumbnailMode thumbnailMode?: ClientThumbnailMode }
}
// DB-backed settings. At present only `theme` is surfaced in `/users/settings`. // DB-backed settings. At present only `theme` is surfaced in `/users/settings`.
// The remaining fields are retained for existing backend work and are not wired // The remaining fields are retained for existing backend work and are not wired
@@ -135,11 +129,8 @@ export type ClientSettings = {
lists?: Partial<Record<ClientListKey, ClientListSettings>> lists?: Partial<Record<ClientListKey, ClientListSettings>>
theatre?: { theatre?: {
layoutMode?: TheatreLayoutMode layoutMode?: TheatreLayoutMode
tagFlow?: TheatreTagFlow tagFlow?: TheatreTagFlow }
} gekanator?: { backgroundMotion?: GekanatorBackgroundMotionMode }
gekanator?: {
backgroundMotion?: GekanatorBackgroundMotionMode
}
reducedMotion?: string reducedMotion?: string
embedAutoLoad?: string embedAutoLoad?: string
thumbnailMode?: string } thumbnailMode?: string }
@@ -153,8 +144,7 @@ export const USER_THEME_SLOT_SELECTIONS: UserThemeSlotSelection[] = [
'user:light:3', 'user:light:3',
'user:dark:1', 'user:dark:1',
'user:dark:2', 'user:dark:2',
'user:dark:3', 'user:dark:3']
]
export const THEME_OPTIONS = ['system', 'light', 'dark'] as const export const THEME_OPTIONS = ['system', 'light', 'dark'] as const
export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const
export const POST_LIST_ORDER_OPTIONS = [ export const POST_LIST_ORDER_OPTIONS = [
@@ -167,8 +157,7 @@ export const POST_LIST_ORDER_OPTIONS = [
'created_at:asc', 'created_at:asc',
'created_at:desc', 'created_at:desc',
'updated_at:asc', 'updated_at:asc',
'updated_at:desc', 'updated_at:desc'] as const satisfies readonly FetchPostsOrder[]
] as const satisfies readonly FetchPostsOrder[]
export const TAG_LIST_ORDER_OPTIONS = [ export const TAG_LIST_ORDER_OPTIONS = [
'name:asc', 'name:asc',
'name:desc', 'name:desc',
@@ -179,8 +168,7 @@ export const TAG_LIST_ORDER_OPTIONS = [
'created_at:asc', 'created_at:asc',
'created_at:desc', 'created_at:desc',
'updated_at:asc', 'updated_at:asc',
'updated_at:desc', 'updated_at:desc'] as const satisfies readonly FetchTagsOrder[]
] as const satisfies readonly FetchTagsOrder[]
export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20 export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20
export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc' export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc'
export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc' export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc'
@@ -191,8 +179,7 @@ export const DEFAULT_LIGHT_THEME_TAG_COLOURS: ThemeTagColours = {
general: '#155e75', general: '#155e75',
material: '#c2410c', material: '#c2410c',
meta: '#a16207', meta: '#a16207',
nico: '#4b5563', nico: '#4b5563' }
}
export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = { export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = {
deerjikist: '#fb7185', deerjikist: '#fb7185',
meme: '#d8b4fe', meme: '#d8b4fe',
@@ -200,8 +187,7 @@ export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = {
general: '#67e8f9', general: '#67e8f9',
material: '#fdba74', material: '#fdba74',
meta: '#fde047', meta: '#fde047',
nico: '#e5e7eb', nico: '#e5e7eb' }
}
export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS
export const LIGHT_THEME_TOKENS: ThemeTokens = { export const LIGHT_THEME_TOKENS: ThemeTokens = {
background: '0 0% 100%', background: '0 0% 100%',
@@ -224,17 +210,13 @@ export const LIGHT_THEME_TOKENS: ThemeTokens = {
input: '214.3 31.8% 91.4%', input: '214.3 31.8% 91.4%',
ring: '222.2 84% 4.9%', ring: '222.2 84% 4.9%',
link: '221.2 83.2% 53.3%', link: '221.2 83.2% 53.3%',
topNavBackground: '48 96% 89%', topNavRootBackgroundMobile: '#fef08a',
topNavForeground: '20 14.3% 4.1%', topNavRootBackgroundDesktop: '#fefce8',
topNavBorder: '45 93% 77%', topNavHighlightBackground: '#fef08a',
topNavLink: '334 84% 57%', topNavSubmenuBackground: '#fef08a',
topNavLinkHover: '334 78% 69%', topNavMobileActiveBackground: '#fefce8',
topNavActiveBackground: '48 96% 89%', topNavLink: '#db2777',
topNavActiveForeground: '20 14.3% 4.1%', tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS }
topNavSubmenuBackground: '48 100% 96%',
topNavSubmenuForeground: '20 14.3% 4.1%',
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS,
}
export const DARK_THEME_TOKENS: ThemeTokens = { export const DARK_THEME_TOKENS: ThemeTokens = {
background: '222.2 84% 4.9%', background: '222.2 84% 4.9%',
foreground: '210 40% 98%', foreground: '210 40% 98%',
@@ -256,33 +238,26 @@ export const DARK_THEME_TOKENS: ThemeTokens = {
input: '217.2 32.6% 17.5%', input: '217.2 32.6% 17.5%',
ring: '212.7 26.8% 83.9%', ring: '212.7 26.8% 83.9%',
link: '213.1 93.9% 67.8%', link: '213.1 93.9% 67.8%',
topNavBackground: '0 49% 20%', topNavRootBackgroundMobile: '#230505',
topNavForeground: '345 100% 93%', topNavRootBackgroundDesktop: '#230505',
topNavBorder: '0 38% 28%', topNavHighlightBackground: '#450a0a',
topNavLink: '335 82% 80%', topNavSubmenuBackground: '#450a0a',
topNavLinkHover: '341 100% 92%', topNavMobileActiveBackground: '#450a0a',
topNavActiveBackground: '0 38% 28%', topNavLink: '#f9a8d4',
topNavActiveForeground: '345 100% 93%', tagColours: DEFAULT_DARK_THEME_TAG_COLOURS }
topNavSubmenuBackground: '0 49% 20%',
topNavSubmenuForeground: '345 100% 93%',
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS,
}
export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = { export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
light: { light: {
id: 'light', id: 'light',
name: 'ライト・モード', name: 'ライト・モード',
builtin: true, builtin: true,
baseTheme: 'light', baseTheme: 'light',
tokens: LIGHT_THEME_TOKENS, tokens: LIGHT_THEME_TOKENS },
},
dark: { dark: {
id: 'dark', id: 'dark',
name: 'ダーク・モード', name: 'ダーク・モード',
builtin: true, builtin: true,
baseTheme: 'dark', baseTheme: 'dark',
tokens: DARK_THEME_TOKENS, tokens: DARK_THEME_TOKENS } }
},
}
const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
@@ -834,6 +809,23 @@ const normaliseThemeTokenValue = (
typeof value === 'string' && value !== '' ? value : fallback typeof value === 'string' && value !== '' ? value : fallback
const normaliseTopNavColourValue = (
value: unknown,
fallback: string,
): string => {
if (typeof value !== 'string' || value === '')
return fallback
const normalisedHex = normaliseHexColour (value)
if (normalisedHex != null)
return normalisedHex
return /^-?\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/.test (value)
? `hsl(${ value })`
: fallback
}
const normaliseThemeTokens = ( const normaliseThemeTokens = (
rawTokens: unknown, rawTokens: unknown,
baseTheme: BuiltinThemeId, baseTheme: BuiltinThemeId,
@@ -843,6 +835,10 @@ const normaliseThemeTokens = (
rawTokens && typeof rawTokens === 'object' rawTokens && typeof rawTokens === 'object'
? rawTokens as Partial<ThemeTokens> ? rawTokens as Partial<ThemeTokens>
: { } : { }
const legacyTopNavTokens =
rawTokens && typeof rawTokens === 'object'
? rawTokens as Record<string, unknown>
: { }
return { return {
background: normaliseThemeTokenValue (tokens.background, fallback.background), background: normaliseThemeTokenValue (tokens.background, fallback.background),
@@ -889,41 +885,29 @@ const normaliseThemeTokens = (
input: normaliseThemeTokenValue (tokens.input, fallback.input), input: normaliseThemeTokenValue (tokens.input, fallback.input),
ring: normaliseThemeTokenValue (tokens.ring, fallback.ring), ring: normaliseThemeTokenValue (tokens.ring, fallback.ring),
link: normaliseThemeTokenValue (tokens.link, fallback.link), link: normaliseThemeTokenValue (tokens.link, fallback.link),
topNavBackground: normaliseThemeTokenValue ( topNavRootBackgroundMobile: normaliseTopNavColourValue (
tokens.topNavBackground, tokens.topNavRootBackgroundMobile ?? legacyTopNavTokens.topNavActiveBackground,
fallback.topNavBackground, fallback.topNavRootBackgroundMobile,
), ),
topNavForeground: normaliseThemeTokenValue ( topNavRootBackgroundDesktop: normaliseTopNavColourValue (
tokens.topNavForeground, tokens.topNavRootBackgroundDesktop ?? legacyTopNavTokens.topNavBackground,
fallback.topNavForeground, fallback.topNavRootBackgroundDesktop,
), ),
topNavBorder: normaliseThemeTokenValue ( topNavHighlightBackground: normaliseTopNavColourValue (
tokens.topNavBorder, tokens.topNavHighlightBackground ?? legacyTopNavTokens.topNavActiveBackground,
fallback.topNavBorder, fallback.topNavHighlightBackground,
), ),
topNavLink: normaliseThemeTokenValue ( topNavSubmenuBackground: normaliseTopNavColourValue (
tokens.topNavLink,
fallback.topNavLink,
),
topNavLinkHover: normaliseThemeTokenValue (
tokens.topNavLinkHover,
fallback.topNavLinkHover,
),
topNavActiveBackground: normaliseThemeTokenValue (
tokens.topNavActiveBackground,
fallback.topNavActiveBackground,
),
topNavActiveForeground: normaliseThemeTokenValue (
tokens.topNavActiveForeground,
fallback.topNavActiveForeground,
),
topNavSubmenuBackground: normaliseThemeTokenValue (
tokens.topNavSubmenuBackground, tokens.topNavSubmenuBackground,
fallback.topNavSubmenuBackground, fallback.topNavSubmenuBackground,
), ),
topNavSubmenuForeground: normaliseThemeTokenValue ( topNavMobileActiveBackground: normaliseTopNavColourValue (
tokens.topNavSubmenuForeground, tokens.topNavMobileActiveBackground ?? legacyTopNavTokens.topNavBackground,
fallback.topNavSubmenuForeground, fallback.topNavMobileActiveBackground,
),
topNavLink: normaliseTopNavColourValue (
tokens.topNavLink,
fallback.topNavLink,
), ),
tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours), tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours),
} }
@@ -945,7 +929,8 @@ const isBuiltinThemeSlotSelection = (
export const isUserThemeSlotSelection = ( export const isUserThemeSlotSelection = (
value: unknown, value: unknown,
): value is UserThemeSlotSelection => ): value is UserThemeSlotSelection =>
typeof value === 'string' && USER_THEME_SLOT_SELECTIONS.includes (value as UserThemeSlotSelection) typeof value === 'string'
&& USER_THEME_SLOT_SELECTIONS.includes (value as UserThemeSlotSelection)
const normaliseThemeSlotSelection = ( const normaliseThemeSlotSelection = (
@@ -1385,20 +1370,29 @@ export const applyThemeTokens = (tokens: ThemeTokens): void => {
root.style.setProperty ('--input', tokens.input) root.style.setProperty ('--input', tokens.input)
root.style.setProperty ('--ring', tokens.ring) root.style.setProperty ('--ring', tokens.ring)
root.style.setProperty ('--theme-link', tokens.link) root.style.setProperty ('--theme-link', tokens.link)
root.style.setProperty ('--top-nav-background', tokens.topNavBackground)
root.style.setProperty ('--top-nav-foreground', tokens.topNavForeground)
root.style.setProperty ('--top-nav-border', tokens.topNavBorder)
root.style.setProperty ('--top-nav-link', tokens.topNavLink)
root.style.setProperty ('--top-nav-link-hover', tokens.topNavLinkHover)
root.style.setProperty ('--top-nav-active-background', tokens.topNavActiveBackground)
root.style.setProperty ('--top-nav-active-foreground', tokens.topNavActiveForeground)
root.style.setProperty ( root.style.setProperty (
'--top-nav-submenu-background', '--top-nav-root-bg-mobile',
tokens.topNavRootBackgroundMobile,
)
root.style.setProperty (
'--top-nav-root-bg-desktop',
tokens.topNavRootBackgroundDesktop,
)
root.style.setProperty (
'--top-nav-highlight-bg',
tokens.topNavHighlightBackground,
)
root.style.setProperty (
'--top-nav-submenu-bg',
tokens.topNavSubmenuBackground, tokens.topNavSubmenuBackground,
) )
root.style.setProperty ( root.style.setProperty (
'--top-nav-submenu-foreground', '--top-nav-mobile-active-bg',
tokens.topNavSubmenuForeground, tokens.topNavMobileActiveBackground,
)
root.style.setProperty (
'--top-nav-link',
tokens.topNavLink,
) )
applyThemeTagColours (tokens.tagColours) applyThemeTagColours (tokens.tagColours)
+1 -1
ファイルの表示
@@ -219,7 +219,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
return return
const actionId = candidates[0].id const actionId = candidates[0].id
const handler = builtinHandlers[actionId] ?? mergedRegisteredHandlers[actionId] const handler = mergedRegisteredHandlers[actionId] ?? builtinHandlers[actionId]
if (handler == null) if (handler == null)
return return
+372 -245
ファイルの表示
@@ -19,6 +19,7 @@ import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { import {
applyClientAppearance,
applyThemeSelection, applyThemeSelection,
DEFAULT_USER_SETTINGS, DEFAULT_USER_SETTINGS,
deriveUserThemeModeFromSlotSelection, deriveUserThemeModeFromSlotSelection,
@@ -37,7 +38,7 @@ import {
upsertUserThemeSlot, upsertUserThemeSlot,
USER_THEME_SLOT_SELECTIONS, USER_THEME_SLOT_SELECTIONS,
} from '@/lib/settings' } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { cn, inputClass } from '@/lib/utils' import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -62,7 +63,7 @@ type Props = {
type UserFormField = 'name' type UserFormField = 'name'
type SettingsFormField = 'theme' type SettingsFormField = 'theme'
type SettingsTab = 'account' | 'theme' | 'keyboard' | 'behavior' type SettingsTab = 'account' | 'behavior' | 'theme' | 'keyboard'
type ThemeTokenKey = Exclude<keyof ThemeTokens, 'tagColours'> type ThemeTokenKey = Exclude<keyof ThemeTokens, 'tagColours'>
type TabSpec = { type TabSpec = {
@@ -112,11 +113,15 @@ type ThemeSectionProps = {
onThemeSubmit: () => void onThemeSubmit: () => void
onThemeDiscard: () => void } onThemeDiscard: () => void }
type SettingsTabDirtyState = {
dirty: boolean
discard: () => void | Promise<void> }
const tabs: TabSpec[] = [ const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' }, { id: 'account', label: 'アカウント' },
{ id: 'behavior', label: '動作' },
{ id: 'theme', label: 'テーマ' }, { id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' }, { id: 'keyboard', label: 'キーボード' } ]
{ id: 'behavior', 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'
@@ -138,25 +143,15 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
fields: [ fields: [
{ key: 'background', label: '背景' }, { key: 'background', label: '背景' },
{ key: 'foreground', label: '文字' }, { key: 'foreground', label: '文字' },
{ key: 'primary', label: '主ボタン' }, { key: 'primary', label: '主' },
{ key: 'primaryForeground', label: '主ボタン文字' },
{ key: 'accent', label: 'アクセント' }, { key: 'accent', label: 'アクセント' },
{ key: 'accentForeground', label: 'アクセント文字' },
] }, ] },
{ title: 'パネル・カード', { title: 'パネル',
fields: [ fields: [
{ key: 'card', label: 'カード背景' }, { key: 'card', label: 'カード背景' },
{ key: 'cardForeground', label: 'カード文字' },
{ key: 'popover', label: 'ポップアップ背景' },
{ key: 'popoverForeground', label: 'ポップアップ文字' },
{ key: 'muted', label: '薄い背景' }, { key: 'muted', label: '薄い背景' },
{ key: 'mutedForeground', label: '薄い文字' },
] },
{ title: '入力欄・境界線',
fields: [
{ key: 'border', label: '境界線' }, { key: 'border', label: '境界線' },
{ key: 'input', label: '入力欄' }, { key: 'input', label: '入力欄' },
{ key: 'ring', label: 'フォーカスリング' },
] }, ] },
{ title: 'リンク', { title: 'リンク',
fields: [ fields: [
@@ -164,15 +159,12 @@ const themeTokenGroups: ThemeTokenGroupSpec[] = [
] }, ] },
{ title: '上部ナビ', { title: '上部ナビ',
fields: [ fields: [
{ key: 'topNavBackground', label: '背景' }, { key: 'topNavRootBackgroundMobile', label: '通常背景' },
{ key: 'topNavForeground', label: '文字' }, { key: 'topNavRootBackgroundDesktop', label: 'PC 背景' },
{ key: 'topNavBorder', label: '境界線' }, { key: 'topNavHighlightBackground', label: '選択中背景' },
{ key: 'topNavLink', label: 'リンク' },
{ key: 'topNavLinkHover', label: 'リンク hover' },
{ key: 'topNavActiveBackground', label: '選択中背景' },
{ key: 'topNavActiveForeground', label: '選択中文字' },
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' }, { key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
{ key: 'topNavSubmenuForeground', label: 'サブメニュー文字' }, { key: 'topNavMobileActiveBackground', label: 'スマホ選択中背景' },
{ key: 'topNavLink', label: 'リンク' },
] }, ] },
] ]
@@ -270,6 +262,10 @@ const themeRowBaseLabel = (
: themeLabelBySelection (selection) : themeLabelBySelection (selection)
const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean =>
key.startsWith ('topNav')
const formatHslComponent = (value: number): string => const formatHslComponent = (value: number): string =>
Number.isInteger (value) ? String (value) : value.toFixed (1).replace (/\.0$/, '') Number.isInteger (value) ? String (value) : value.toFixed (1).replace (/\.0$/, '')
@@ -367,6 +363,30 @@ const hexToHslToken = (hex: string): string => {
} }
const themeTokenToHex = (
key: ThemeTokenKey,
value: string,
): string => {
if (isTopNavThemeTokenKey (key))
{
if (value.startsWith ('#'))
return value
const match = value.match (/^hsl\((.+)\)$/)
return match == null ? '#000000' : hslTokenToHex (match[1])
}
return hslTokenToHex (value)
}
const hexToThemeToken = (
key: ThemeTokenKey,
value: string,
): string =>
isTopNavThemeTokenKey (key) ? value : hexToHslToken (value)
const AccountSection: FC<AccountSectionProps> = ( const AccountSection: FC<AccountSectionProps> = (
{ name, { name,
onInheritOpen, onInheritOpen,
@@ -443,183 +463,190 @@ const ThemeSection: FC<ThemeSectionProps> = (
onResetThemeSlot, onResetThemeSlot,
onThemeSubmit, onThemeSubmit,
onThemeDiscard }, onThemeDiscard },
) => ( ) => {
<section className={sectionClassName}> const currentSelection =
<div className="flex flex-wrap items-start justify-between gap-3"> themeSelections.find (item => item.id === draftActiveThemeSlot)
<div className="space-y-1"> ?? themeSelections[0]
<h2 className="text-xl font-bold"></h2> const currentEditableSelection =
<p className="text-sm text-muted-foreground"> editingThemeSlot
: {selectedThemeLabel} ?? (isUserThemeSlotSelection (draftActiveThemeSlot)
</p> ? draftActiveThemeSlot
{editingThemeSlot && ( : null)
<p className="text-sm text-muted-foreground"> const canEditTheme = currentEditableSelection != null
: {themeLabelBySelection (editingThemeSlot)}
</p>)} return (
{hasUnsavedChanges && ( <section className={sectionClassName}>
<p className="text-sm text-amber-700 dark:text-amber-300"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
</p>)} <h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground">
: {selectedThemeLabel}
</p>
{editingThemeSlot && (
<p className="text-sm text-muted-foreground">
: {themeLabelBySelection (editingThemeSlot)}
</p>)}
{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"
disabled={!(hasUnsavedChanges)}
onClick={onThemeDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges)}
onClick={onThemeSubmit}>
</Button>
</div>
</div> </div>
<div className="flex flex-wrap gap-2"> <FieldError messages={settingsBaseErrors}/>
<Button <FieldError messages={settingsFieldErrors.theme ?? []}/>
type="button"
variant="outline"
disabled={!(hasUnsavedChanges)}
onClick={onThemeDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges)}
onClick={onThemeSubmit}>
</Button>
</div>
</div>
<FieldError messages={settingsBaseErrors}/>
<FieldError messages={settingsFieldErrors.theme ?? []}/>
<div className="space-y-3">
{themeSelections.map (selection => {
const themeSlot =
selection.editable
? draftThemeSlots[selection.id as UserThemeSlotSelection]
: null
const isSelected = draftActiveThemeSlot === selection.id
const isEditing = editingThemeSlot === selection.id
return (
<div
key={selection.id}
className={cn (
'space-y-3 rounded-xl border border-border/70 bg-background/70 p-4',
isSelected && 'ring-1 ring-primary/30',
)}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold">{selection.label}</p>
<p className="text-sm text-muted-foreground">
{themeRowBaseLabel (selection.id)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={isSelected ? 'default' : 'outline'}
onClick={() => onSelectThemeSlot (selection.id)}>
</Button>
{selection.editable && (
<>
<Button
type="button"
variant={isEditing ? 'default' : 'outline'}
onClick={() =>
onEditThemeSlot (selection.id as UserThemeSlotSelection)}>
</Button>
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges) || !(isEditing || isSelected)}
onClick={onThemeSubmit}>
</Button>
<Button
type="button"
variant="ghost"
onClick={() =>
onResetThemeSlot (selection.id as UserThemeSlotSelection)}>
</Button>
</>)}
</div>
</div>
{themeSlot && (
<div style={themePreviewStyle (themeSlot.tokens.tagColours)}>
<TagLink
tag={previewTags.general}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>)}
</div>)
})}
</div>
<div className="space-y-4">
{themeTokenGroups.map (group => (
<div
key={group.title}
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<h3 className="text-sm font-semibold">{group.title}</h3>
<div className="space-y-3">
{group.fields.map (field => (
<div
key={field.key}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{field.label}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={hslTokenToHex (selectedTheme.tokens[field.key])}
onPointerDown={onEnsureEditableTheme}
onFocus={onEnsureEditableTheme}
onChange={ev =>
onThemeTokenChange (field.key, hexToHslToken (ev.target.value))}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
</div>
</div>))}
</div>
</div>))}
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4"> <div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="space-y-2">
<div> <Label></Label>
<h3 className="font-medium"></h3> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<select
className={cn (inputClass (false), 'w-full min-w-0 sm:w-64')}
value={draftActiveThemeSlot}
onChange={ev =>
onSelectThemeSlot (ev.target.value as ThemeSlotSelection)}>
{themeSelections.map (selection => (
<option key={selection.id} value={selection.id}>
{selection.label}
</option>))}
</select>
<Button
type="button"
variant="outline"
onClick={() => {
if (isUserThemeSlotSelection (draftActiveThemeSlot))
{
onEditThemeSlot (draftActiveThemeSlot)
return
}
onEnsureEditableTheme ()
}}>
{canEditTheme ? 'この枠を編輯' : '編輯する枠を選ぶ'}
</Button>
{currentEditableSelection && (
<Button
type="button"
variant="ghost"
onClick={() => onResetThemeSlot (currentEditableSelection)}>
</Button>)}
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-1">
{CATEGORIES.map (category => ( <p className="text-sm font-semibold">{currentSelection.label}</p>
<div <p className="text-sm text-muted-foreground">
key={category} {themeRowBaseLabel (currentSelection.id)}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3 </p>
sm:flex-row sm:items-center sm:justify-between"> {canEditTheme
<div className="space-y-2"> ? (
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p> <p className="text-sm text-muted-foreground">
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink </p>)
tag={previewTags[category]} : (
linkFlg={false} <p className="text-sm text-muted-foreground">
truncateOnMobile
withWiki={false}/> </p>)}
</div> </div>
</div>
<div className="flex flex-wrap items-center gap-3"> <div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<input <TagLink
type="color" tag={previewTags.general}
value={selectedTheme.tokens.tagColours[category]} linkFlg={false}
onPointerDown={onEnsureEditableTheme} truncateOnMobile
onFocus={onEnsureEditableTheme} withWiki={false}/>
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
</div>
</div>))}
</div> </div>
</div> </div>
</div>
</section>) <div className="space-y-4">
{themeTokenGroups.map (group => (
<div
key={group.title}
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<h3 className="text-sm font-semibold">{group.title}</h3>
<div className="space-y-3">
{group.fields.map (field => (
<div
key={field.key}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{field.label}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
disabled={!(canEditTheme)}
onChange={ev =>
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/>
</div>
</div>))}
</div>
</div>))}
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
</div>
</div>
<div className="space-y-3">
{CATEGORIES.map (category => (
<div
key={category}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
tag={previewTags[category]}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={selectedTheme.tokens.tagColours[category]}
disabled={!(canEditTheme)}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/>
</div>
</div>))}
</div>
</div>
</div>
</section>)
}
const SettingPage: FC<Props> = ({ user, setUser }) => { const SettingPage: FC<Props> = ({ user, setUser }) => {
@@ -642,6 +669,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ })) useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
const [editingThemeSlot, setEditingThemeSlot] = const [editingThemeSlot, setEditingThemeSlot] =
useState<UserThemeSlotSelection | null> (null) useState<UserThemeSlotSelection | null> (null)
const [dirtyStates, setDirtyStates] =
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
const [userCodeVsbl, setUserCodeVsbl] = useState (false) const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const { conflictActionIds } = useKeyboardShortcutSettings () const { conflictActionIds } = useKeyboardShortcutSettings ()
const { baseErrors: userBaseErrors, const { baseErrors: userBaseErrors,
@@ -676,7 +705,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
[draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots], [draftActiveThemeSlot, draftThemeSlots, savedActiveThemeSlot, savedThemeSlots],
) )
const setActiveTab = (tab: SettingsTab) => { const applyActiveTab = (tab: SettingsTab) => {
const params = new URLSearchParams (location.search) const params = new URLSearchParams (location.search)
params.set ('tab', tab) params.set ('tab', tab)
navigate ( navigate (
@@ -684,12 +713,43 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{ replace: true }) { replace: true })
} }
const updateTabDirtyState = (
tab: SettingsTab,
state: SettingsTabDirtyState,
) => {
setDirtyStates (current => {
const next = { ...current }
if (!(state.dirty))
{
delete next[tab]
return next
}
next[tab] = state
return next
})
}
const confirmDiscardChanges = async (
description: string,
cancelText: string,
confirmText: string,
): Promise<boolean> =>
dialogue.confirm ({
title: '未保存の変更があります',
description,
confirmText,
cancelText,
variant: 'danger',
})
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> ( const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
() => ( () => (
{ account: Boolean (userFieldErrors.name?.length), { account: Boolean (userFieldErrors.name?.length),
behavior: false,
theme: Boolean (settingsFieldErrors.theme?.length), theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: conflictActionIds.size > 0, keyboard: conflictActionIds.size > 0 }),
behavior: false }),
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name], [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name],
) )
@@ -706,29 +766,65 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
applyThemeSelection (activeThemeSlot, themeSlots) applyThemeSelection (activeThemeSlot, themeSlots)
} }
const confirmThemeSlotSwitch = async ( const discardThemeChanges = () => {
onConfirmed: (themeSlots: UserThemeSlotMap) => void,
): Promise<void> => {
if (!(hasUnsavedThemeChanges))
{
onConfirmed (draftThemeSlots)
return
}
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま切り替えると、保存していない変更は失われます。',
confirmText: '変更を破棄して切り替える',
cancelText: 'このまま編輯する',
})
if (!(confirmed))
return
setDraftActiveThemeSlot (savedActiveThemeSlot) setDraftActiveThemeSlot (savedActiveThemeSlot)
setDraftThemeSlots (savedThemeSlots) setDraftThemeSlots (savedThemeSlots)
setEditingThemeSlot (null) setEditingThemeSlot (null)
onConfirmed (savedThemeSlots) setCachedUserThemeSlots (savedThemeSlots)
setClientActiveThemeSlot (savedActiveThemeSlot)
applyClientAppearance (savedThemeSlots)
}
const requestActiveTab = async (
nextTab: SettingsTab,
): Promise<void> => {
if (nextTab === activeTab)
return
const currentDirtyState = dirtyStates[activeTab]
if (!(currentDirtyState?.dirty))
{
applyActiveTab (nextTab)
return
}
const confirmed = await confirmDiscardChanges (
'このタブを離れると、保存していない変更は失われます。',
'このタブに残る',
'変更を破棄して移動',
)
if (!(confirmed))
return
await currentDirtyState.discard ()
applyActiveTab (nextTab)
}
const requestThemeSlotSelection = async (
selection: ThemeSlotSelection,
editingSlot: UserThemeSlotSelection | null,
): Promise<void> => {
if (
selection === draftActiveThemeSlot
&& editingSlot === editingThemeSlot
)
return
if (hasUnsavedThemeChanges)
{
const confirmed = await confirmDiscardChanges (
'このまま切り替えると、保存していない変更は失われます。',
'この枠に残る',
'変更を破棄して切り替える',
)
if (!(confirmed))
return
discardThemeChanges ()
}
setEditingThemeSlot (editingSlot)
applyDraftThemeSelection (selection, savedThemeSlots)
} }
const ensureEditableThemeSlot = async (): Promise<UserThemeSlotSelection | null> => { const ensureEditableThemeSlot = async (): Promise<UserThemeSlotSelection | null> => {
@@ -756,7 +852,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
return null return null
setEditingThemeSlot (choice) setEditingThemeSlot (choice)
applyDraftThemeSelection (choice, draftThemeSlots) applyDraftThemeSelection (choice, savedThemeSlots)
return choice return choice
} }
@@ -790,23 +886,23 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
case 'ArrowDown': case 'ArrowDown':
case 'ArrowRight': case 'ArrowRight':
event.preventDefault () event.preventDefault ()
setActiveTab (tabs[(currentIndex + 1) % tabs.length].id) void requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
break break
case 'ArrowUp': case 'ArrowUp':
case 'ArrowLeft': case 'ArrowLeft':
event.preventDefault () event.preventDefault ()
setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id) void requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
break break
case 'Home': case 'Home':
event.preventDefault () event.preventDefault ()
setActiveTab (tabs[0].id) void requestActiveTab (tabs[0].id)
break break
case 'End': case 'End':
event.preventDefault () event.preventDefault ()
setActiveTab (tabs[tabs.length - 1].id) void requestActiveTab (tabs[tabs.length - 1].id)
break break
} }
} }
@@ -889,7 +985,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
) )
setClientActiveThemeSlot (draftActiveThemeSlot) setClientActiveThemeSlot (draftActiveThemeSlot)
setCachedUserThemeSlots (completeThemeSlots) setCachedUserThemeSlots (completeThemeSlots)
applyThemeSelection (draftActiveThemeSlot, completeThemeSlots) applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' }) toast ({ title: 'テーマ設定を保存しました.' })
} }
catch (submitError) catch (submitError)
@@ -900,14 +996,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
} }
const handleThemeDiscard = () => { const handleThemeDiscard = () => {
setDraftActiveThemeSlot (savedActiveThemeSlot) discardThemeChanges ()
setDraftThemeSlots (savedThemeSlots)
setEditingThemeSlot (
isUserThemeSlotSelection (savedActiveThemeSlot)
? savedActiveThemeSlot
: null,
)
applyThemeSelection (savedActiveThemeSlot, savedThemeSlots)
} }
useEffect (() => { useEffect (() => {
@@ -985,12 +1074,12 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
useEffect (() => { useEffect (() => {
if (userFieldErrors.name?.length) if (userFieldErrors.name?.length)
setActiveTab ('account') applyActiveTab ('account')
}, [location.pathname, location.search, userFieldErrors.name]) }, [location.pathname, location.search, userFieldErrors.name])
useEffect (() => { useEffect (() => {
if (settingsFieldErrors.theme?.length) if (settingsFieldErrors.theme?.length)
setActiveTab ('theme') applyActiveTab ('theme')
}, [location.pathname, location.search, settingsFieldErrors.theme]) }, [location.pathname, location.search, settingsFieldErrors.theme])
useEffect (() => { useEffect (() => {
@@ -1015,6 +1104,28 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
} }
: null : null
useEffect (() => {
updateTabDirtyState ('theme', {
dirty: hasUnsavedThemeChanges,
discard: handleThemeDiscard,
})
}, [hasUnsavedThemeChanges, savedActiveThemeSlot, savedThemeSlots])
useKeyboardShortcuts ({
'settings.account': () => {
void requestActiveTab ('account')
},
'settings.behavior': () => {
void requestActiveTab ('behavior')
},
'settings.theme': () => {
void requestActiveTab ('theme')
},
'settings.keyboard': () => {
void requestActiveTab ('keyboard')
},
})
const themeSectionProps: ThemeSectionProps = { const themeSectionProps: ThemeSectionProps = {
draftActiveThemeSlot, draftActiveThemeSlot,
draftThemeSlots, draftThemeSlots,
@@ -1025,16 +1136,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
settingsBaseErrors, settingsBaseErrors,
settingsFieldErrors, settingsFieldErrors,
onSelectThemeSlot: selection => { onSelectThemeSlot: selection => {
void confirmThemeSlotSwitch (themeSlots => { void requestThemeSlotSelection (selection, null)
setEditingThemeSlot (null)
applyDraftThemeSelection (selection, themeSlots)
})
}, },
onEditThemeSlot: selection => { onEditThemeSlot: selection => {
void confirmThemeSlotSwitch (themeSlots => { void requestThemeSlotSelection (selection, selection)
setEditingThemeSlot (selection)
applyDraftThemeSelection (selection, themeSlots)
})
}, },
onEnsureEditableTheme: () => { onEnsureEditableTheme: () => {
void ensureEditableThemeSlot () void ensureEditableThemeSlot ()
@@ -1067,7 +1172,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
}) () }) ()
}, },
onResetThemeSlot: selection => { onResetThemeSlot: selection => {
const baseTheme = draftThemeSlots[selection]?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId const baseTheme =
draftThemeSlots[selection]?.baseTheme
?? selection.split (':')[1] as BuiltinThemeId
updateDraftThemeSlot ( updateDraftThemeSlot (
selection, selection,
() => getDefaultUserThemeSlot ( () => getDefaultUserThemeSlot (
@@ -1111,7 +1218,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
'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-border bg-background text-foreground']), : ['border-border bg-background text-foreground']),
)} )}
onClick={() => setActiveTab (tab.id)}> onClick={() => {
void requestActiveTab (tab.id)
}}>
{tab.label} {tab.label}
{settingsTabErrors[tab.id] ? ' !' : ''} {settingsTabErrors[tab.id] ? ' !' : ''}
</button>))} </button>))}
@@ -1125,9 +1234,17 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<AccountSection {...accountSectionProps}/>)} <AccountSection {...accountSectionProps}/>)}
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>} {activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
{activeTab === 'keyboard' && ( {activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)} <KeyboardSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('keyboard', { dirty, discard })
}}/>)}
{activeTab === 'behavior' && ( {activeTab === 'behavior' && (
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)} <BehaviourSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('behavior', { dirty, discard })
}}/>)}
</div>)} </div>)}
</div> </div>
@@ -1155,7 +1272,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
: ['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={() => {
void requestActiveTab (tab.id)
}}
onKeyDown={handleTabKeyDown}> onKeyDown={handleTabKeyDown}>
{tab.label} {tab.label}
{settingsTabErrors[tab.id] ? ' ⚠' : ''} {settingsTabErrors[tab.id] ? ' ⚠' : ''}
@@ -1174,9 +1293,17 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<AccountSection {...accountSectionProps}/>)} <AccountSection {...accountSectionProps}/>)}
{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>} {activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
{activeTab === 'keyboard' && ( {activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)} <KeyboardSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('keyboard', { dirty, discard })
}}/>)}
{activeTab === 'behavior' && ( {activeTab === 'behavior' && (
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)} <BehaviourSettingsSection
sectionClassName={sectionClassName}
onDirtyStateChange={(dirty, discard) => {
updateTabDirtyState ('behavior', { dirty, discard })
}}/>)}
</div>)} </div>)}
</div> </div>
</div> </div>