設定画面 (#34) #397
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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')}>
|
||||||
その他 »
|
その他 »
|
||||||
</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'))}>
|
||||||
その他 »
|
その他 »
|
||||||
</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'))}>
|
||||||
その他 »
|
その他 »
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<TopNavUser user={user} sp/>
|
<TopNavUser user={user} sp/>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする