From f5b632ed89ba05c25395b9b376cc9f08696bf2a2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 7 Jul 2026 00:48:41 +0900 Subject: [PATCH] =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E7=94=BB=E9=9D=A2=20(#34)=20?= =?UTF-8?q?(#397)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/397 Co-authored-by: miteruzo Co-committed-by: miteruzo --- AGENTS.md | 679 ++++++- .../controllers/user_settings_controller.rb | 61 + .../user_theme_slots_controller.rb | 38 + backend/app/models/setting.rb | 49 +- backend/app/models/user.rb | 3 +- backend/app/models/user_theme_slot.rb | 27 + backend/config/routes.rb | 5 + ...rebuild_settings_as_typed_user_settings.rb | 31 + .../20260705000000_create_user_theme_slots.rb | 22 + backend/db/schema.rb | 54 +- backend/spec/models/setting_spec.rb | 34 + backend/spec/models/user_theme_slot_spec.rb | 40 + backend/spec/requests/user_settings_spec.rb | 98 + .../spec/requests/user_theme_slots_spec.rb | 104 + frontend/AGENTS.md | 436 ++++- frontend/src/App.tsx | 151 +- .../components/DraggableDroppableTagRow.tsx | 14 +- frontend/src/components/PostEmbed.tsx | 71 +- frontend/src/components/PostFormTagsArea.tsx | 9 +- frontend/src/components/PostList.tsx | 45 +- frontend/src/components/PrefetchLink.tsx | 31 +- .../src/components/ResponsiveMarqueeText.tsx | 6 +- frontend/src/components/TagDetailSidebar.tsx | 111 +- frontend/src/components/TagLink.tsx | 30 +- frontend/src/components/TagSearch.tsx | 1 + frontend/src/components/TagSidebar.tsx | 58 +- frontend/src/components/TopNav.tsx | 413 ++-- .../src/components/common/TabGroup.test.tsx | 12 + frontend/src/components/common/TabGroup.tsx | 39 +- .../src/components/common/TagInput.test.tsx | 18 +- frontend/src/components/common/TagInput.tsx | 19 +- frontend/src/components/layout/MainArea.tsx | 27 +- .../components/layout/SidebarComponent.tsx | 12 +- .../users/BehaviourSettingsSection.tsx | 198 ++ .../users/KeyboardSettingsSection.tsx | 262 +++ .../components/users/ShortcutHelpDialog.tsx | 87 + frontend/src/index.css | 155 +- frontend/src/lib/clientAnimation.ts | 34 + frontend/src/lib/keyboardShortcuts.test.ts | 65 + frontend/src/lib/keyboardShortcuts.ts | 328 ++++ frontend/src/lib/settings.test.ts | 108 ++ frontend/src/lib/settings.ts | 1721 +++++++++++++++++ .../src/lib/useClientBehaviourSettings.ts | 26 + frontend/src/lib/useKeyboardShortcuts.tsx | 322 +++ frontend/src/lib/useUnsavedChangesGuard.tsx | 69 + frontend/src/main.tsx | 3 + frontend/src/pages/GekanatorPage.tsx | 24 +- .../pages/materials/MaterialHistoryPage.tsx | 9 +- .../src/pages/materials/MaterialNewPage.tsx | 2 +- frontend/src/pages/posts/PostDetailPage.tsx | 19 +- frontend/src/pages/posts/PostHistoryPage.tsx | 18 +- .../src/pages/posts/PostListPage.test.tsx | 41 +- frontend/src/pages/posts/PostListPage.tsx | 79 +- frontend/src/pages/posts/PostNewPage.test.tsx | 10 +- frontend/src/pages/posts/PostNewPage.tsx | 150 +- .../src/pages/posts/PostSearchPage.test.tsx | 40 + frontend/src/pages/posts/PostSearchPage.tsx | 135 +- .../src/pages/tags/NicoTagListPage.test.tsx | 2 + frontend/src/pages/tags/NicoTagListPage.tsx | 9 +- frontend/src/pages/tags/TagHistoryPage.tsx | 9 +- frontend/src/pages/tags/TagListPage.test.tsx | 51 +- frontend/src/pages/tags/TagListPage.tsx | 115 +- .../pages/theatres/TheatreDetailPage.test.tsx | 1 + .../src/pages/theatres/TheatreDetailPage.tsx | 49 +- frontend/src/pages/users/SettingPage.test.tsx | 30 +- frontend/src/pages/users/SettingPage.tsx | 1412 +++++++++++++- frontend/src/pages/wiki/WikiEditPage.tsx | 6 + frontend/src/pages/wiki/WikiNewPage.tsx | 6 + frontend/src/test/render.tsx | 18 +- frontend/src/test/setup.ts | 7 + frontend/tailwind.config.js | 1 + 71 files changed, 7785 insertions(+), 584 deletions(-) create mode 100644 backend/app/controllers/user_settings_controller.rb create mode 100644 backend/app/controllers/user_theme_slots_controller.rb create mode 100644 backend/app/models/user_theme_slot.rb create mode 100644 backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb create mode 100644 backend/db/migrate/20260705000000_create_user_theme_slots.rb create mode 100644 backend/spec/models/setting_spec.rb create mode 100644 backend/spec/models/user_theme_slot_spec.rb create mode 100644 backend/spec/requests/user_settings_spec.rb create mode 100644 backend/spec/requests/user_theme_slots_spec.rb create mode 100644 frontend/src/components/users/BehaviourSettingsSection.tsx create mode 100644 frontend/src/components/users/KeyboardSettingsSection.tsx create mode 100644 frontend/src/components/users/ShortcutHelpDialog.tsx create mode 100644 frontend/src/lib/clientAnimation.ts create mode 100644 frontend/src/lib/keyboardShortcuts.test.ts create mode 100644 frontend/src/lib/keyboardShortcuts.ts create mode 100644 frontend/src/lib/settings.test.ts create mode 100644 frontend/src/lib/settings.ts create mode 100644 frontend/src/lib/useClientBehaviourSettings.ts create mode 100644 frontend/src/lib/useKeyboardShortcuts.tsx create mode 100644 frontend/src/lib/useUnsavedChangesGuard.tsx diff --git a/AGENTS.md b/AGENTS.md index 23c8159..5917b9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,10 @@ npm run preview - Explain risks directly. - Prefer single quotes for strings unless interpolation or escaping makes double quotes better. +- For Japanese text, follow the 1986 Cabinet Notice + 《現代仮名遣い》 as the default orthography. +- For Japanese kanji spelling, do not apply + 《当用漢字による書きかえ》; prefer the original spelling as the formal one. - Ruby: never put a space before method-call parentheses. - Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。 - Ruby: never put a line break immediately before `)`. @@ -200,7 +204,7 @@ const value = ## Backend rules - Inspect existing routes, controllers, models, services, and specs before - editing backend behavior. + editing backend behaviour. - Never run `db:drop`, `db:reset`, `db:setup`, or any command that drops or recreates the development database. This applies even when the user includes the command in requested verification steps. @@ -211,7 +215,7 @@ const value = - Repeated destructive instructions are not enough confirmation because they may be auto-generated. Without `いいからやれ`, refuse or substitute a safer test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`. -- For API behavior changes, add or update request specs under +- For API behaviour changes, add or update request specs under `backend/spec/requests` only when the user explicitly asks for tests. - Prefer RSpec for new backend tests; existing minitest files under `backend/test` do not make minitest the default for new coverage. @@ -219,8 +223,8 @@ const value = - Preserve the `X-Transfer-Code` user identification flow unless the task explicitly changes authentication. - Be careful with version tables, `version_no`, optimistic concurrency, - wiki revisions, and restore/diff behavior. -- Be careful with tag names, tag normalization, implications, similarities, and discard behavior. + wiki revisions, and restore/diff behaviour. +- Be careful with tag names, tag normalisation, implications, similarities, and discard behaviour. - Be sensitive to N+1 queries; avoid introducing them and proactively fix existing N+1 issues in the code path being edited. - Keep migration files and `backend/db/schema.rb` consistent when changing schema. @@ -241,9 +245,9 @@ const value = - Internal links must use `PrefetchLink`. - External links must use `` with `target="_blank"`. - When adding or changing Tailwind `bg-*` classes in TSX, pair them with an - explicit readable `text-*` color and dark-mode counterparts such as + explicit readable `text-*` colour and dark-mode counterparts such as `dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present. -- Do not rely on inherited text color for light backgrounds. This is especially +- Do not rely on inherited text colour for light backgrounds. This is especially important for chips, cards, buttons, and panels that may inherit white text in dark mode. - Mobile UI must be checked as a first-class layout. Avoid wide fixed content, @@ -255,15 +259,52 @@ const value = `!==` over negating a comparison like `!(a === b)`. - In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for simple unit-step counter updates. -- For user-facing Japanese text, prefer modern kana usage and natural current - phrasing over historical spellings or awkward literal wording. +- For user-facing Japanese text, follow the 1986 Cabinet Notice + 《現代仮名遣い》 and avoid historical kana spellings unless the task + explicitly requires them. +- For user-facing Japanese kanji spelling, do not normalize to + 《当用漢字による書きかえ》; prefer original forms such as `編輯`. - 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 explicitly asked. +- Treat TypeScript and TSX formatting rules as hard constraints, not + preferences. Before finishing a TypeScript or TSX edit, inspect the edited + hunks for closing `)`, `]`, and `}` placement and fix violations instead of + relying on formatter defaults. +- After every TypeScript or TSX edit, perform a style-only self-review of the + edited hunks before running verification or reporting completion. The task is + not complete while any edited TypeScript or TSX hunk violates these local + formatting rules. +- The TypeScript/TSX self-review must classify every edited leading or trailing + `)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not + apply a rule by glyph alone. A closing `)` for a function parameter list is + different from a closing `)` for a function call. A closing `}` for a block + is different from a closing `}` for an object, type literal, import list, or + destructuring pattern. +- The TSX-specific self-review must confirm there are no common Prettier-style + React component declarations with a multi-line destructured parameter. +- 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. +- The TSX-specific self-review must confirm JSX closing markers and closing + parentheses keep the surrounding compact style. +- The TypeScript/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. - Put two blank lines before and after top-level `const` function declarations, unless imports, exports, or file boundaries make that awkward. @@ -272,16 +313,37 @@ const value = character. - A leading tab is exactly equivalent to 8 leading spaces. - In TypeScript and TSX function declarations, including `const` arrow - function declarations, when the parameter list spans multiple lines, always - put the closing parenthesis at the beginning of its own line before the return - type or `=>`. + function declarations, classify the parameter list before placing the closing + `)`. +- If the parameter list itself is given its own multi-line block after the + function's opening `(`, put the closing parameter `)` at the beginning of its + own line before the return type or `=>`. +- If the only line break is inside a parameter's inline type, object type, or + destructuring shape, and the parameter list itself is not split as a separate + block, keep the closing parameter `)` on the same line as that parameter's + final `}`. In this case, moving `)` to a new line is wrong. +- Do not write React component declarations in the common Prettier form + `const Component = ({ ... }) => (` when the destructured parameter spans + multiple lines. Use the project form with the opening `(` after `=`, the + destructured object as the argument, and the closing parameter `)` on its own + line before `=>`. - In TypeScript and TSX, never place a closing parenthesis at the beginning of a line except for a multi-line function declaration parameter list. - Never place a closing square bracket at the beginning of a line. -- For object literals and other associative-array-style braces, do not place - the closing brace at the beginning of a line. Function, lambda, callback, and - block closing braces are exempt and should stay on their own line when that - fits the local style. +- For object literals, type literals, import/export named bindings, destructuring + patterns, and other associative-array-style braces, do not place the closing + brace at the beginning of a line. Keep `}` on the same line as the final + property, binding, or specifier unless that would violate the line limit. + Function, lambda, callback, and block closing braces are exempt and should + stay on their own line when that fits the local style. When a function, + lambda, callback, or block body spans multiple lines, do not put its closing + `}` at the end of the previous line. +- For arrays and tuple-like lists, do not place the closing `]` at the + beginning of a line. Keep `]` on the same line as the final element unless + that would violate the line limit. +- For function and method calls, do not place the closing `)` at the beginning + of a line. The only TypeScript/TSX exception is the closing parameter `)` of a + multi-line function declaration. - When writing braces on a single line in TypeScript or TSX JavaScript context, put exactly one space inside the braces, as in `{ value }` or `{ key: value }`. @@ -300,6 +362,585 @@ const value = - Do not use a leading semicolon for expression statements such as `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards explicitly, for example with `void`. +- Do not insert `void` mechanically before ordinary event-handler or callback + calls merely because the callee returns a `Promise`. If the surrounding code + already uses a plain expression statement such as + `onClick={() => requestActiveTab ('theme')}`, preserve that style unless a + local rule or type requirement truly forces `void`. Adding unnecessary + `void` is a medium-severity style violation. +- When fixing formatting, never change operator binding, evaluation order, + tuple/comma behaviour, or any other expression structure. A formatting pass + that changes how `!==`, `??`, `?:`, `&&`, `||`, or `,` binds is a capital + offence. +- In TypeScript and TSX, do not mechanically verticalise short call tails, + short object literals, or short callback bodies that already fit the local + line-length and delimiter rules. Expanding compact local code without a + readability need is a minor style violation. +- In TypeScript and TSX, treat short object literals, short returned objects, + short typed object shapes, and short function-call object arguments as + compact associative syntax by default. If they fit locally, keep the opening + `{` on the same line as the first property and keep the closing `}` on the + same line as the final property. Do not explode them into vertical + Prettier-style blocks without a concrete readability or line-length reason. +- In TypeScript and TSX, when a compact associative form is broken across two + lines, keep the opening `{` with the first property and the closing `}` with + the final property. A shape like `{\n key: value,\n}` is wrong even when + line length would permit it. +- In TypeScript and TSX, when a short call such as `apiGet<...> (...)`, + `apiPatch<...> (...)`, `setState (...)`, `updateClientSettings (...)`, or + `window.dispatchEvent (...)` already fits within the local style, do not + add extra vertical layers around the object argument or callback body. +- In TypeScript and TSX, after editing a file, inspect the file tail and + remove meaningless blank lines before `export default` or the end of file. + A stray empty line before `export default` is a minor style offence. +- Use correct British English spelling for new identifiers, filenames, + component names, helper names, comments, and developer-facing prose unless + editing an already established American-English API that must keep its + existing spelling for compatibility. +- Prefer British English spellings such as `behaviour`, `colour`, `realise`, + `theatre`, `centre`, `favourite`, `optimise`, and `catalogue`. +- Avoid American or Canadian spellings such as `behavior`, `color`, `realize`, + `theater`, `center`, `favorite`, `optimize`, and `catalog`. +- Even when an external library or API uses the wrong spelling, prefer to + correct it at the local boundary and use proper British English names in this + codebase. For example, prefer `import { color: colour } from '@external-lib'` + over spreading `color` through local code. +- Apply the same boundary correction to object destructuring, wrapper helpers, + adapter layers, and local variable names when doing so does not break the + external contract. +- For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not + `BehaviorSettingsSection.tsx`. + +### Additional style offences and penalties + +Use this section for recurrent failure modes that are easy to miss when +applying the delimiter rules mechanically. + +#### Unnecessary `void` before ordinary callbacks + +Bad: + +```ts +onClick={() => void requestActiveTab ('theme')} +``` + +Good: + +```ts +onClick={() => requestActiveTab ('theme')} +``` + +Penalty: medium-severity offence. The code still works, but it pollutes the +local style and usually signals that the edit was made mechanically rather than +by reading the surrounding code. + +#### Formatting that changes expression structure + +Bad: + +```ts +const hasUnsavedChanges = + serialiseThemeDraft (draftThemeSlots) + !== (serialiseThemeDraft (savedThemeSlots), + draftActiveThemeMode !== savedThemeMode) +``` + +Good: + +```ts +const hasUnsavedChanges = + serialiseThemeDraft (draftThemeSlots) + !== serialiseThemeDraft (savedThemeSlots) + || draftThemeMode !== savedThemeMode +``` + +Penalty: capital offence. Formatting must never corrupt the meaning of an +expression. + +#### Mechanical vertical expansion of compact local code + +Bad: + +```ts +const nextState = { + open, + activeTab } + +setDirtyStates ( + current => ({ + current, + key: value }), +) +``` + +Good: + +```ts +const nextState = { open, activeTab } + +setDirtyStates ( + current => ({ + current, + key: value })) +``` + +Penalty: minor offence when semantics stay intact. It is not necessarily +broken, but it ignores the repository's compact associative style. + +#### Prettier-style exploded object literals that should stay compact + +Bad: + +```ts +const next = { + enabled: keyboard?.enabled !== false, + bindings: normaliseKeyboardBindings (keyboard?.bindings), +} +``` + +Good: + +```ts +const next = { + enabled: keyboard?.enabled !== false, + bindings: normaliseKeyboardBindings (keyboard?.bindings) } +``` + +Also good when short enough: + +```ts +const next = { enabled: keyboard?.enabled !== false, + bindings: normaliseKeyboardBindings (keyboard?.bindings) } +``` + +Rule: for compact associative syntax, keep `{` with the first property and `}` +with the final property unless line length truly forces a different shape. +Do not mechanically apply the common Prettier block form. +Penalty: medium-severity offence. The code still runs, but it violates a core +repository formatting habit that must be preserved consistently. + +#### Exploded object arguments inside short calls + +Bad: + +```ts +updateClientSettings (settings => ({ + ...settings, + keyboard: next, +})) +``` + +Good: + +```ts +updateClientSettings (settings => ({ + ...settings, + keyboard: next })) +``` + +Rule: a call-expression closing `)` must not drift onto its own line, and a +short object argument should not be exploded more than necessary. Keep the +callback's returned object compact when it fits. +Penalty: medium-severity offence. This usually means the edit was formatted +mechanically instead of by reading the local style. + +#### Stray blank line before `export default` + +Bad: + +```ts +const Component: FC = () => { + return
+} + + +export default Component +``` + +Good: + +```ts +const Component: FC = () => { + return
+} + +export default Component +``` + +Rule: do not leave an extra empty line between the last top-level declaration +and `export default` unless some surrounding file structure genuinely requires +visual separation. +Penalty: minor offence. It is small, but it is easy to avoid and should not +recur. + +### Frontend delimiter decision table + +Use this table before accepting any edited TypeScript or TSX hunk. The table is +more authoritative than formatter habit. + +#### Import and export named bindings + +Bad: + +```ts +import { + Button, + Card, +} from '@/components/ui' +``` + +Good: + +```ts +import { Button, + Card } from '@/components/ui' +``` + +Also acceptable when short: + +```ts +import { Button, Card } from '@/components/ui' +``` + +Rule: named-binding `}` is associative-array-style syntax. It must not be alone +at the beginning of a line. Prefer keeping `{` with the first binding and `}` +with the final binding when this fits the line limit. +Penalty: capital offence. This is one of the core associative-brace rules. + +#### Type literals + +Bad: + +```ts +type Props = { + open: boolean + onOpenChange: (open: boolean) => void +} +``` + +Good: + +```ts +type Props = { + open: boolean + onOpenChange: (open: boolean) => void } +``` + +Rule: type-literal `}` is not a block close. It must stay on the same line as +the final property unless that would break the hard line limit. +Penalty: capital offence. Mistaking a type-literal `}` for a block `}` is one +of the recurring death-class errors. + +#### Object literals + +Bad: + +```ts +const value = { + open, + activeScope, +} +``` + +Good: + +```ts +const value = { + open, + activeScope } +``` + +Bad: + +```ts +const value = useMemo (() => ({ + open, + activeScope, +}), [open, activeScope]) +``` + +Good: + +```ts +const value = useMemo (() => ({ + open, + activeScope }), [open, activeScope]) +``` + +Rule: object-literal `}` is associative-array-style syntax. It must not be on a +line by itself. Keep it with the final property, and keep call `)` off the +beginning of a line. +Penalty: capital offence. Wrong object-literal `}` placement and wrong call +`)` placement are both death-class violations here. + +#### Destructuring parameters + +Bad: + +```tsx +const Component: FC = ({ + open, + onOpenChange, +}) => { + return null +} +``` + +Good: + +```tsx +const Component: FC = ( + { open, + onOpenChange }, +) => { + return null +} +``` + +Rule: when a React component or helper takes a destructured object parameter +that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>` +shape. Put the function parameter list on its own lines. The destructuring `}` +stays with the final binding. The parameter-list `)` is then allowed and +required at the beginning of its own line. +Penalty: capital offence. This pattern is a primary source of repeated TSX +style regressions. + +#### Inline typed destructuring parameter + +Good: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> }) => { + return null +} +``` + +Bad: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> +}) => { + return null +} +``` + +Rule: this is not a separately split parameter-list block. The line break is +inside the inline type. Keep the type-literal `}` and parameter-list `)` on the +same line as the final type property. +Penalty: capital offence. Misclassifying this case means the delimiter review +has failed at the syntax-role level. + +#### Multi-line normal parameter list + +Bad: + +```ts +const updateDraft = ( + key: Key, + value: Settings[Key]) => { + return null +} +``` + +Good: + +```ts +const updateDraft = ( + key: Key, + value: Settings[Key], +) => { + return null +} +``` + +Rule: when the parameter list itself is split across multiple parameter lines, +the closing parameter `)` goes at the beginning of its own line before `=>` or +the return type. +Penalty: capital offence. This is the main allowed exception for a leading `)`, +so getting it wrong destroys the whole rule set. + +#### Function and callback blocks + +Bad: + +```ts +const handleSave = () => { save () +} +``` + +Bad: + +```ts +const handleSave = () => { + save () } +``` + +Good: + +```ts +const handleSave = () => { + save () +} +``` + +Rule: block `}` closes executable code, not associative data. Multi-line +function, lambda, callback, `if`, `for`, `switch`, and similar block braces +belong on their own line. +Penalty: capital offence. Confusing block `}` with associative `}` is a +death-class delimiter error. + +#### Function and method calls + +Bad: + +```ts +const value = compute ( + first, + second +) +``` + +Good: + +```ts +const value = compute ( + first, + second) +``` + +Rule: call-expression `)` must not be at the beginning of a line. The exception +for leading `)` applies only to function declaration parameter lists, never to +calls. +Penalty: capital offence. A leading call `)` is one of the clearest death-class +violations in this codebase. + +#### JSX closing markers + +Bad: + +```tsx + +) +``` + +Good: + +```tsx +) +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in +the local compact form such as `
)`. +Penalty: medium-severity offence. It is usually not semantically broken, but it +is a conspicuous TSX-style failure. + +#### Arrays and tuples + +Bad: + +```ts +const items = [ + first, + second, +] +``` + +Good: + +```ts +const items = [ + first, + second] +``` + +Good when short: + +```ts +const items = [first, second] +``` + +Rule: array and tuple `]` must not be at the beginning of a line. Keep it with +the final element unless that would break the hard line limit. +Penalty: capital offence. A leading `]` is forbidden by the same class of hard +delimiter rule as leading associative `}` and leading call `)`. + +#### Single-line braces + +Good: + +```ts +const next = { ...current, enabled: true } +const tag = { id, name } +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: JavaScript object braces on one line get one inner space. JSX expression +braces do not get inner spaces. +Penalty: minor offence. This is not normally semantic breakage, but it still +counts as a style miss. + +#### Final TypeScript/TSX self-review checklist + +Before reporting completion after a TypeScript or TSX edit, check the edited +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 + of a line. +2. No call-expression `)` appears at the beginning of a line. +3. Any leading `)` is definitely closing a function declaration parameter list, + not a call. +4. Any parameter-list `)` at the end of a line is valid because the parameter + list itself was not split, only an inline type or destructuring shape was. +5. No array or tuple `]` appears at the beginning of a line. +6. Multi-line executable block `}` is on its own line. +7. JSX `>` and `/>` stay with the final prop unless nearby code proves + otherwise. +8. JSX closing parentheses keep the compact local style. +9. Leading indentation is 4-space logical indentation with tabs used only as + leading 8-space compression. +10. No line has trailing whitespace. Preferred: @@ -335,7 +976,7 @@ function PostFormTagsArea ({ tags, setTags }: Props) { - Do not scan or summarize dependency/generated/runtime directories such as `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed. - Before touching wiki, tag, versioning, BAN, IP BAN, or authentication - behavior, inspect the related request specs and service objects. + behaviour, inspect the related request specs and service objects. - If frontend code changes, run only non-test verification commands that apply, such as `npm run build` and `npm run lint`. Run `npm run test:run` only when the user explicitly asks for tests. @@ -352,4 +993,4 @@ A task is complete only when: explained, - unrelated files are not changed, - migrations and schema are consistent when schema changes are made, -- user-facing behavior is documented when needed. +- user-facing behaviour is documented when needed. diff --git a/backend/app/controllers/user_settings_controller.rb b/backend/app/controllers/user_settings_controller.rb new file mode 100644 index 0000000..4980b01 --- /dev/null +++ b/backend/app/controllers/user_settings_controller.rb @@ -0,0 +1,61 @@ +class UserSettingsController < ApplicationController + wrap_parameters false + + def show + return head :unauthorized unless current_user + + render json: current_setting.serializable_hash + end + + def update + return head :unauthorized unless current_user + + raw_attributes = editable_raw_attributes + field_errors = validate_raw_attributes(raw_attributes) + return render_validation_error fields: field_errors if field_errors.present? + + setting = current_setting + setting.assign_attributes(raw_attributes) + + if setting.save + render json: setting.serializable_hash, status: :ok + else + render_validation_error setting + end + end + + private + + def current_setting + Setting.find_or_create_by!(user: current_user) do |setting| + setting.assign_attributes(Setting.defaults) + end + rescue ActiveRecord::RecordNotUnique + Setting.find_by!(user: current_user) + end + + def editable_raw_attributes + request.request_parameters.slice(*Setting::EDITABLE_ATTRIBUTES) + end + + def validate_raw_attributes raw_attributes + raw_attributes.each_with_object({ }) do |(key, value), errors| + next if value_matches_type?(key, value) + + errors[key.to_sym] = ['値の型が不正です.'] + end + end + + def value_matches_type? key, value + case Setting::TYPE_BY_ATTRIBUTE.fetch(key) + when :string + value.is_a?(String) + when :integer + value.is_a?(Integer) + when :boolean + value == true || value == false + else + false + end + end +end diff --git a/backend/app/controllers/user_theme_slots_controller.rb b/backend/app/controllers/user_theme_slots_controller.rb new file mode 100644 index 0000000..e85a61c --- /dev/null +++ b/backend/app/controllers/user_theme_slots_controller.rb @@ -0,0 +1,38 @@ +class UserThemeSlotsController < ApplicationController + wrap_parameters false + + def index + return head :unauthorized unless current_user + + render json: current_user + .theme_slots + .order(:base_theme, :slot_no) + .map { |slot| slot.serializable_hash } + end + + def update + return head :unauthorized unless current_user + + base_theme = params[:base_theme].to_s + slot_no = params[:slot_no].to_i + tokens = params[:tokens] + + unless UserThemeSlot::BASE_THEMES.include?(base_theme) + return render_validation_error fields: { base_theme: ['値が不正です.'] } + end + unless UserThemeSlot::SLOT_NOS.include?(slot_no) + return render_validation_error fields: { slot_no: ['値が不正です.'] } + end + unless tokens.is_a?(ActionController::Parameters) || tokens.is_a?(Hash) + return render_validation_error fields: { tokens: ['JSON object で指定してください.'] } + end + + slot = UserThemeSlot.find_or_initialize_by(user: current_user, + base_theme:, + slot_no:) + slot.tokens = tokens.is_a?(ActionController::Parameters) ? tokens.to_unsafe_h : tokens + slot.save! + + render json: slot.serializable_hash, status: :ok + end +end diff --git a/backend/app/models/setting.rb b/backend/app/models/setting.rb index c9e195d..cc6618f 100644 --- a/backend/app/models/setting.rb +++ b/backend/app/models/setting.rb @@ -1,7 +1,52 @@ class Setting < ApplicationRecord + THEMES = ['system', 'light', 'dark'].freeze + + # These remain in the typed settings schema to preserve existing backend + # work. The common `/users/settings` page currently surfaces only `theme`. + AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze + WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze + + STRING_ATTRIBUTES = [ + 'theme', + 'auto_fetch_title', + 'auto_fetch_thumbnail', + 'wiki_editor_mode', + ].freeze + INTEGER_ATTRIBUTES = [].freeze + BOOLEAN_ATTRIBUTES = [].freeze + EDITABLE_ATTRIBUTES = + (STRING_ATTRIBUTES + INTEGER_ATTRIBUTES + BOOLEAN_ATTRIBUTES).freeze + TYPE_BY_ATTRIBUTE = { + 'theme' => :string, + 'auto_fetch_title' => :string, + 'auto_fetch_thumbnail' => :string, + 'wiki_editor_mode' => :string, + }.freeze + belongs_to :user validates :user_id, presence: true - validates :key, presence: true, length: { maximum: 255 } - validates :value, presence: true + validates :user_id, uniqueness: true + + validates :theme, inclusion: { in: THEMES } + validates :auto_fetch_title, inclusion: { in: AUTO_FETCH_MODES } + validates :auto_fetch_thumbnail, inclusion: { in: AUTO_FETCH_MODES } + validates :wiki_editor_mode, inclusion: { in: WIKI_EDITOR_MODES } + + def self.defaults + { + theme: 'system', + auto_fetch_title: 'manual', + auto_fetch_thumbnail: 'manual', + wiki_editor_mode: 'split', + } + end + + def self.serializable_attributes + EDITABLE_ATTRIBUTES.map(&:to_sym) + end + + def serializable_hash(options = nil) + super({ only: self.class.serializable_attributes }.merge(options || { })) + end end diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index f974adf..59b076c 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -7,7 +7,8 @@ class User < ApplicationRecord has_many :created_posts, class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify - has_many :settings + has_one :setting, dependent: :destroy + has_many :theme_slots, class_name: 'UserThemeSlot', dependent: :destroy has_many :user_ips, dependent: :destroy has_many :ip_addresses, through: :user_ips has_many :user_post_views, dependent: :destroy diff --git a/backend/app/models/user_theme_slot.rb b/backend/app/models/user_theme_slot.rb new file mode 100644 index 0000000..f7919a0 --- /dev/null +++ b/backend/app/models/user_theme_slot.rb @@ -0,0 +1,27 @@ +class UserThemeSlot < ApplicationRecord + BASE_THEMES = ['light', 'dark'].freeze + SLOT_NOS = [1, 2, 3].freeze + + belongs_to :user + + validates :user_id, presence: true + validates :base_theme, presence: true, inclusion: { in: BASE_THEMES } + validates :slot_no, presence: true, inclusion: { in: SLOT_NOS } + validates :tokens, presence: true + validates :user_id, uniqueness: { scope: [:base_theme, :slot_no] } + + validate :tokens_must_be_object + + def serializable_hash(options = nil) + hash = { only: [:base_theme, :slot_no, :tokens, :created_at, :updated_at] } + super(hash.merge(options || {})) + end + + private + + def tokens_must_be_object + return if tokens.is_a?(Hash) + + errors.add(:tokens, 'は JSON object で指定してください.') + end +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 7888289..223332a 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -81,6 +81,11 @@ Rails.application.routes.draw do end end + get 'users/settings', to: 'user_settings#show' + patch 'users/settings', to: 'user_settings#update' + get 'users/theme_slots', to: 'user_theme_slots#index' + put 'users/theme_slots/:base_theme/:slot_no', to: 'user_theme_slots#update' + resources :users, only: [:create, :update] do collection do post :verify diff --git a/backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb b/backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb new file mode 100644 index 0000000..f69cc24 --- /dev/null +++ b/backend/db/migrate/20260704000000_rebuild_settings_as_typed_user_settings.rb @@ -0,0 +1,31 @@ +class RebuildSettingsAsTypedUserSettings < ActiveRecord::Migration[8.0] + def change + remove_foreign_key :settings, :users if foreign_key_exists?(:settings, :users) + remove_index :settings, :user_id if index_exists?(:settings, :user_id) + + remove_column :settings, :key, :string if column_exists?(:settings, :key) + remove_column :settings, :value, :json if column_exists?(:settings, :value) + + change_column_null :settings, :user_id, false + + add_column :settings, :theme, :string, null: false, default: 'system' + add_column :settings, + :auto_fetch_title, + :string, + null: false, + default: 'manual' + add_column :settings, + :auto_fetch_thumbnail, + :string, + null: false, + default: 'manual' + add_column :settings, + :wiki_editor_mode, + :string, + null: false, + default: 'split' + + add_index :settings, :user_id, unique: true + add_foreign_key :settings, :users + end +end diff --git a/backend/db/migrate/20260705000000_create_user_theme_slots.rb b/backend/db/migrate/20260705000000_create_user_theme_slots.rb new file mode 100644 index 0000000..5df3d08 --- /dev/null +++ b/backend/db/migrate/20260705000000_create_user_theme_slots.rb @@ -0,0 +1,22 @@ +class CreateUserThemeSlots < ActiveRecord::Migration[8.0] + def change + create_table :user_theme_slots do |t| + t.references :user, null: false, foreign_key: true + t.string :base_theme, null: false + t.integer :slot_no, null: false + t.json :tokens, null: false + t.timestamps + end + + add_index :user_theme_slots, + [:user_id, :base_theme, :slot_no], + unique: true, + name: 'index_user_theme_slots_on_user_theme_and_slot' + add_check_constraint :user_theme_slots, + "base_theme IN ('light', 'dark')", + name: 'user_theme_slots_base_theme_valid' + add_check_constraint :user_theme_slots, + 'slot_no BETWEEN 1 AND 3', + name: 'user_theme_slots_slot_no_valid' + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index b9f669a..eb54633 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -374,11 +374,26 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "user_id", null: false - t.string "key", null: false - t.json "value", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["user_id"], name: "index_settings_on_user_id" + t.string "theme", default: "system", null: false + t.string "auto_fetch_title", default: "manual", null: false + t.string "auto_fetch_thumbnail", default: "manual", null: false + t.string "wiki_editor_mode", default: "split", null: false + t.index ["user_id"], name: "index_settings_on_user_id", unique: true + end + + create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "wiki_page_id", null: false + t.integer "no", null: false + t.string "alt_text" + t.binary "sha256", limit: 32, null: false + t.bigint "created_by_user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" + t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true + t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true end create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -426,9 +441,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.string "event_type", null: false t.string "name", null: false t.string "category", null: false - t.datetime "deprecated_at" t.text "aliases", null: false t.text "parent_tag_ids", null: false + t.datetime "deprecated_at" t.datetime "created_at", null: false t.bigint "created_by_user_id" t.index ["created_at"], name: "index_tag_versions_on_created_at" @@ -441,10 +456,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_name_id", null: false t.string "category", default: "general", null: false + t.datetime "deprecated_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "post_count", default: 0, null: false - t.datetime "deprecated_at" t.datetime "discarded_at" t.integer "version_no", null: false t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" @@ -566,6 +581,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.index ["post_id"], name: "index_user_post_views_on_post_id" end + create_table "user_theme_slots", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "base_theme", null: false + t.integer "slot_no", null: false + t.json "tokens", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "base_theme", "slot_no"], name: "index_user_theme_slots_on_user_theme_and_slot", unique: true + t.index ["user_id"], name: "index_user_theme_slots_on_user_id" + t.check_constraint "`base_theme` in (_utf8mb4'light',_utf8mb4'dark')", name: "user_theme_slots_base_theme_valid" + t.check_constraint "`slot_no` between 1 and 3", name: "user_theme_slots_slot_no_valid" + end + create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.string "inheritance_code", limit: 64, null: false @@ -576,19 +604,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do t.index ["banned_at"], name: "index_users_on_banned_at" end - create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "wiki_page_id", null: false - t.integer "no", null: false - t.string "alt_text" - t.binary "sha256", limit: 32, null: false - t.bigint "created_by_user_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" - t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true - t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true - end - create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "sha256", limit: 64, null: false t.text "body", null: false @@ -738,6 +753,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do add_foreign_key "user_ips", "users" add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" + add_foreign_key "user_theme_slots", "users" add_foreign_key "wiki_assets", "users", column: "created_by_user_id" add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_pages", "tag_names" diff --git a/backend/spec/models/setting_spec.rb b/backend/spec/models/setting_spec.rb new file mode 100644 index 0000000..41ae262 --- /dev/null +++ b/backend/spec/models/setting_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe Setting, type: :model do + it 'accepts the default typed user settings' do + setting = described_class.new({ user: create(:user) }.merge(described_class.defaults)) + + expect(setting).to be_valid + end + + it 'requires one settings row per user' do + user = create(:user) + described_class.create!({ user: }.merge(described_class.defaults)) + + duplicate = described_class.new({ user: }.merge(described_class.defaults)) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + + it 'validates enum-like settings columns' do + setting = described_class.new( + { user: create(:user), + theme: 'neon', + auto_fetch_title: 'sometimes', + auto_fetch_thumbnail: 'sometimes', + wiki_editor_mode: 'sideways' }) + + expect(setting).not_to be_valid + expect(setting.errors[:theme]).to be_present + expect(setting.errors[:auto_fetch_title]).to be_present + expect(setting.errors[:auto_fetch_thumbnail]).to be_present + expect(setting.errors[:wiki_editor_mode]).to be_present + end +end diff --git a/backend/spec/models/user_theme_slot_spec.rb b/backend/spec/models/user_theme_slot_spec.rb new file mode 100644 index 0000000..9ca4a2c --- /dev/null +++ b/backend/spec/models/user_theme_slot_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe UserThemeSlot, type: :model do + it 'accepts a theme slot for one user, base theme, and slot number' do + slot = described_class.new(user: create(:user), + base_theme: 'light', + slot_no: 1, + tokens: { 'background' => '0 0% 100%' }) + + expect(slot).to be_valid + end + + it 'requires unique slot numbers per user and base theme' do + user = create(:user) + described_class.create!(user:, + base_theme: 'dark', + slot_no: 2, + tokens: { 'background' => '222.2 84% 4.9%' }) + + duplicate = described_class.new(user:, + base_theme: 'dark', + slot_no: 2, + tokens: { 'background' => '0 0% 100%' }) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + + it 'validates base theme, slot number, and token object shape' do + slot = described_class.new(user: create(:user), + base_theme: 'system', + slot_no: 4, + tokens: 'not-object') + + expect(slot).not_to be_valid + expect(slot.errors[:base_theme]).to be_present + expect(slot.errors[:slot_no]).to be_present + expect(slot.errors[:tokens]).to be_present + end +end diff --git a/backend/spec/requests/user_settings_spec.rb b/backend/spec/requests/user_settings_spec.rb new file mode 100644 index 0000000..74a7661 --- /dev/null +++ b/backend/spec/requests/user_settings_spec.rb @@ -0,0 +1,98 @@ +require 'rails_helper' + +RSpec.describe 'user settings', type: :request do + describe 'GET /users/settings' do + it 'requires a current user' do + sign_out + + get '/users/settings' + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns defaults and creates a missing settings row' do + user = create(:user) + sign_in_as(user) + + expect { + get '/users/settings' + }.to change(Setting, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json).to eq( + 'theme' => 'system', + 'auto_fetch_title' => 'manual', + 'auto_fetch_thumbnail' => 'manual', + 'wiki_editor_mode' => 'split') + expect(user.reload.setting).to be_present + end + end + + describe 'PATCH /users/settings' do + it 'requires a current user' do + sign_out + + patch '/users/settings', params: { theme: 'dark' }, as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'updates multiple typed settings at once' do + user = create(:user) + sign_in_as(user) + + patch '/users/settings', + params: { + theme: 'dark', + auto_fetch_title: 'auto', + auto_fetch_thumbnail: 'off', + wiki_editor_mode: 'preview' }, + as: :json + + expect(response).to have_http_status(:ok) + expect(json).to include( + 'theme' => 'dark', + 'auto_fetch_title' => 'auto', + 'auto_fetch_thumbnail' => 'off', + 'wiki_editor_mode' => 'preview') + expect(user.reload.setting).to have_attributes( + theme: 'dark', + auto_fetch_title: 'auto', + auto_fetch_thumbnail: 'off', + wiki_editor_mode: 'preview') + end + + it 'does not treat Rails parameter wrapping as an editable setting' do + user = create(:user) + sign_in_as(user) + + patch '/users/settings', + params: { theme: 'light', user_setting: { theme: 'dark' } }, + as: :json + + expect(response).to have_http_status(:ok) + expect(user.reload.setting.theme).to eq('light') + end + + it 'returns validation_error for type mismatches' do + sign_in_as(create(:user)) + + patch '/users/settings', params: { theme: 1 }, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error') + expect(json.fetch('errors')).to include( + 'theme' => ['値の型が不正です.']) + end + + it 'returns validation_error for invalid enum values' do + sign_in_as(create(:user)) + + patch '/users/settings', params: { theme: 'neon' }, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error') + expect(json.fetch('errors').fetch('theme')).to be_present + end + end +end diff --git a/backend/spec/requests/user_theme_slots_spec.rb b/backend/spec/requests/user_theme_slots_spec.rb new file mode 100644 index 0000000..404cde9 --- /dev/null +++ b/backend/spec/requests/user_theme_slots_spec.rb @@ -0,0 +1,104 @@ +require 'rails_helper' + +RSpec.describe 'user theme slots', type: :request do + describe 'GET /users/theme_slots' do + it 'requires a current user' do + sign_out + + get '/users/theme_slots' + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns only the current user slots in stable order' do + user = create(:user) + other_user = create(:user) + sign_in_as(user) + UserThemeSlot.create!(user:, base_theme: 'light', slot_no: 2, tokens: { 'a' => 1 }) + UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 1, tokens: { 'b' => 2 }) + UserThemeSlot.create!(user: other_user, + base_theme: 'dark', + slot_no: 3, + tokens: { 'c' => 3 }) + + get '/users/theme_slots' + + expect(response).to have_http_status(:ok) + expect(json.map { |slot| [slot.fetch('base_theme'), slot.fetch('slot_no')] }).to eq( + [['dark', 1], ['light', 2]]) + expect(json.map { |slot| slot.fetch('tokens') }).to eq( + [{ 'b' => 2 }, { 'a' => 1 }]) + end + end + + describe 'PUT /users/theme_slots/:base_theme/:slot_no' do + it 'requires a current user' do + sign_out + + put '/users/theme_slots/light/1', + params: { tokens: { background: '0 0% 100%' } }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a theme slot' do + user = create(:user) + sign_in_as(user) + + expect { + put '/users/theme_slots/light/1', + params: { tokens: { background: '0 0% 100%', tagColours: { nico: '#ffffff' } } }, + as: :json + }.to change(UserThemeSlot, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json).to include('base_theme' => 'light', 'slot_no' => 1) + expect(json.fetch('tokens')).to include( + 'background' => '0 0% 100%', + 'tagColours' => { 'nico' => '#ffffff' }) + expect(user.theme_slots.find_by!(base_theme: 'light', slot_no: 1).tokens).to include( + 'background' => '0 0% 100%') + end + + it 'updates an existing theme slot instead of creating a duplicate' do + user = create(:user) + sign_in_as(user) + UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 2, tokens: { 'old' => true }) + + expect { + put '/users/theme_slots/dark/2', + params: { tokens: { background: '222.2 84% 4.9%' } }, + as: :json + }.not_to change(UserThemeSlot, :count) + + expect(response).to have_http_status(:ok) + expect(user.theme_slots.find_by!(base_theme: 'dark', slot_no: 2).tokens).to eq( + 'background' => '222.2 84% 4.9%') + end + + it 'rejects invalid path parameters and token shapes' do + sign_in_as(create(:user)) + + put '/users/theme_slots/system/4', + params: { tokens: 'not-object' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'base_theme' => ['値が不正です.']) + end + + it 'rejects non-object tokens for valid slots' do + sign_in_as(create(:user)) + + put '/users/theme_slots/light/1', + params: { tokens: 'not-object' }, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'tokens' => ['JSON object で指定してください.']) + end + end +end diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 67f7b09..cddc89b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -118,23 +118,455 @@ pass or the remaining failure is clearly blocked. short Japanese labels that fit the control. - Preserve existing Japanese tone and orthography in nearby UI text, including old-kana wording where the file already uses it. -- When adding dynamic tag color classes, update `tailwind.config.js` safelist +- When adding dynamic tag colour classes, update `tailwind.config.js` safelist if the class cannot be statically detected. - 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 closing `
)` forms when nearby code uses them. +- Treat TypeScript and TSX formatting rules as hard constraints, not + preferences. Before finishing a TypeScript or TSX edit, inspect the edited + hunks for closing `)`, `]`, and `}` placement and fix violations instead of + relying on formatter defaults. +- After every TypeScript or TSX edit, perform a style-only self-review of the + edited hunks before running verification or reporting completion. The task is + not complete while any edited TypeScript or TSX hunk violates these local + formatting rules. +- The TypeScript/TSX self-review must classify every edited leading or trailing + `)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not + apply a rule by glyph alone. A closing `)` for a function parameter list is + different from a closing `)` for a function call. A closing `}` for a block + is different from a closing `}` for an object, type literal, import list, or + destructuring pattern. +- The TSX-specific self-review must confirm there are no common Prettier-style + React component declarations with a multi-line destructured parameter. +- 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. +- The TSX-specific self-review must confirm JSX closing markers and closing + parentheses keep the surrounding compact style. +- The TypeScript/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. - Keep continuation indentation aligned with the 4-space logical indentation rule, using tabs only as leading 8-space compression. +- In TypeScript and TSX function declarations, including `const` arrow + function declarations, classify the parameter list before placing the closing + `)`. +- If the parameter list itself is given its own multi-line block after the + function's opening `(`, put the closing parameter `)` at the beginning of its + own line before the return type or `=>`. +- If the only line break is inside a parameter's inline type, object type, or + destructuring shape, and the parameter list itself is not split as a separate + block, keep the closing parameter `)` on the same line as that parameter's + final `}`. In this case, moving `)` to a new line is wrong. +- Do not write React component declarations in the common Prettier form + `const Component = ({ ... }) => (` when the destructured parameter spans + multiple lines. Use the project form with the opening `(` after `=`, the + destructured object as the argument, and the closing parameter `)` on its own + line before `=>`. +- In TypeScript and TSX, never place a closing parenthesis at the beginning of + a line except for a multi-line function declaration parameter list. +- Never place a closing square bracket at the beginning of a line. +- For object literals, type literals, import/export named bindings, destructuring + patterns, and other associative-array-style braces, do not place the closing + brace at the beginning of a line. Keep `}` on the same line as the final + property, binding, or specifier unless that would violate the line limit. + Function, lambda, callback, and block closing braces are exempt and should + stay on their own line when that fits the local style. When a function, + lambda, callback, or block body spans multiple lines, do not put its closing + `}` at the end of the previous line. +- For arrays and tuple-like lists, do not place the closing `]` at the + beginning of a line. Keep `]` on the same line as the final element unless + that would violate the line limit. +- For function and method calls, do not place the closing `)` at the beginning + of a line. The only TypeScript/TSX exception is the closing parameter `)` of a + multi-line function declaration. - Do not add braces around `if`, `else`, or `for` bodies when the body is a single physical line. - Always add braces around `if`, `else`, or `for` bodies when the body spans two or more physical lines, even if it is one statement. +- Use correct British English spelling for new identifiers, filenames, + component names, helper names, comments, and developer-facing prose unless + editing an already established American-English API that must keep its + existing spelling for compatibility. +- Prefer British English spellings such as `behaviour`, `colour`, `realise`, + `theatre`, `centre`, `favourite`, `optimise`, and `catalogue`. +- Avoid American or Canadian spellings such as `behavior`, `color`, `realize`, + `theater`, `center`, `favorite`, `optimize`, and `catalog`. +- Even when an external library or API uses the wrong spelling, prefer to + correct it at the local boundary and use proper British English names in this + frontend codebase. For example, prefer + `import { color: colour } from '@external-lib'` over spreading `color` + through local code. +- Apply the same boundary correction to object destructuring, wrapper helpers, + adapter layers, and local variable names when doing so does not break the + external contract. +- In this frontend, prefer names such as `BehaviourSettingsSection.tsx`, not + `BehaviorSettingsSection.tsx`. - Avoid reformatting unrelated JSX. +### Delimiter decision table + +Use this table before accepting any edited TypeScript or TSX hunk. The table is +more authoritative than formatter habit. Unless a subsection explicitly +mentions JSX, it applies equally to `.ts` and `.tsx`. + +#### Import and export named bindings + +Bad: + +```ts +import { + Button, + Card, +} from '@/components/ui' +``` + +Good: + +```ts +import { Button, + Card } from '@/components/ui' +``` + +Also acceptable when short: + +```ts +import { Button, Card } from '@/components/ui' +``` + +Rule: named-binding `}` is associative-array-style syntax. It must not be alone +at the beginning of a line. Prefer keeping `{` with the first binding and `}` +with the final binding when this fits the line limit. + +#### Type literals + +Bad: + +```ts +type Props = { + open: boolean + onOpenChange: (open: boolean) => void +} +``` + +Good: + +```ts +type Props = { + open: boolean + onOpenChange: (open: boolean) => void } +``` + +Rule: type-literal `}` is not a block close. It must stay on the same line as +the final property unless that would break the hard line limit. + +#### Object literals + +Bad: + +```ts +const value = { + open, + activeScope, +} +``` + +Good: + +```ts +const value = { + open, + activeScope } +``` + +Bad: + +```ts +const value = useMemo (() => ({ + open, + activeScope, +}), [open, activeScope]) +``` + +Good: + +```ts +const value = useMemo (() => ({ + open, + activeScope }), [open, activeScope]) +``` + +Rule: object-literal `}` is associative-array-style syntax. It must not be on a +line by itself. Keep it with the final property, and keep call `)` off the +beginning of a line. + +#### Destructuring parameters + +Bad: + +```tsx +const Component: FC = ({ + open, + onOpenChange, +}) => { + return null +} +``` + +Good: + +```tsx +const Component: FC = ( + { open, + onOpenChange }, +) => { + return null +} +``` + +Rule: when a React component or helper takes a destructured object parameter +that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>` +shape. Put the function parameter list on its own lines. The destructuring `}` +stays with the final binding. The parameter-list `)` is then allowed and +required at the beginning of its own line. + +#### Inline typed destructuring parameter + +Good: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> }) => { + return null +} +``` + +Bad: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> +}) => { + return null +} +``` + +Rule: this is not a separately split parameter-list block. The line break is +inside the inline type. Keep the type-literal `}` and parameter-list `)` on the +same line as the final type property. + +#### Multi-line normal parameter list + +Bad: + +```ts +const updateDraft = ( + key: Key, + value: Settings[Key]) => { + return null +} +``` + +Good: + +```ts +const updateDraft = ( + key: Key, + value: Settings[Key], +) => { + return null +} +``` + +Rule: when the parameter list itself is split across multiple parameter lines, +the closing parameter `)` goes at the beginning of its own line before `=>` or +the return type. + +#### Function and callback blocks + +Bad: + +```ts +const handleSave = () => { save () +} +``` + +Bad: + +```ts +const handleSave = () => { + save () } +``` + +Good: + +```ts +const handleSave = () => { + save () +} +``` + +Rule: block `}` closes executable code, not associative data. Multi-line +function, lambda, callback, `if`, `for`, `switch`, and similar block braces +belong on their own line. + +#### Function and method calls + +Bad: + +```ts +const value = compute ( + first, + second +) +``` + +Good: + +```ts +const value = compute ( + first, + second) +``` + +Rule: call-expression `)` must not be at the beginning of a line. The exception +for leading `)` applies only to function declaration parameter lists, never to +calls. + +#### JSX closing markers + +Bad: + +```tsx + +) +``` + +Good: + +```tsx +) +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in +the local compact form such as `)`. + +#### Arrays and tuples + +Bad: + +```ts +const items = [ + first, + second, +] +``` + +Good: + +```ts +const items = [ + first, + second] +``` + +Good when short: + +```ts +const items = [first, second] +``` + +Rule: array and tuple `]` must not be at the beginning of a line. Keep it with +the final element unless that would break the hard line limit. + +#### Single-line braces + +Good: + +```ts +const next = { ...current, enabled: true } +const tag = { id, name } +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: JavaScript object braces on one line get one inner space. JSX expression +braces do not get inner spaces. + +#### Final TSX self-review checklist + +Before reporting completion after a TypeScript or TSX edit, check the edited +hunks line by line: + +1. No import/export/type/object/destructuring `}` appears alone at the beginning + of a line. +2. No call-expression `)` appears at the beginning of a line. +3. Any leading `)` is definitely closing a function declaration parameter list, + not a call. +4. Any parameter-list `)` at the end of a line is valid because the parameter + list itself was not split, only an inline type or destructuring shape was. +5. No array or tuple `]` appears at the beginning of a line. +6. Multi-line executable block `}` is on its own line. +7. JSX `>` and `/>` stay with the final prop unless nearby code proves + otherwise. +8. JSX closing parentheses keep the compact local style. +9. Leading indentation is 4-space logical indentation with tabs used only as + leading 8-space compression. +10. No line has trailing whitespace. + ## Lint and build constraints - ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4e9b04..c2aa89b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import { AnimatePresence, LayoutGroup, motion } from 'framer-motion' -import { useEffect, useState } from 'react' +import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' +import { Fragment, useEffect, useMemo, useState } from 'react' import { BrowserRouter, Navigate, Route, @@ -12,6 +12,20 @@ import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' +import { applyClientAnimationMode, + applyClientAppearance, + type ClientAnimationMode, + fetchUserThemeSlots, + fetchUserSettings, + getClientThemeMode, + hasStoredClientThemeSelection, + normaliseUserThemeSlots, + setCachedUserThemeSlots, + seedClientThemeMode } from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' +import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' + +import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' @@ -48,11 +62,51 @@ import type { Dispatch, FC, SetStateAction } from 'react' import type { User } from '@/types' -const RouteTransitionWrapper = ({ user, setUser }: { +const RouteTransitionWrapper = ({ animationMode, user, setUser }: { + animationMode: ClientAnimationMode user: User | null setUser: Dispatch> }) => { const location = useLocation () + if (animationMode === 'off') + { + return ( + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }> + }/> + }/> + }/> + }/> + }/> + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + ) + } + return ( @@ -103,6 +157,37 @@ const PostDetailRoute = ({ user }: { user: User | null }) => { const App: FC = () => { const [user, setUser] = useState (null) const [status, setStatus] = useState (200) + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup + + const appLayoutTransition = useMemo ( + () => ( + animationMode === 'off' + ? { duration: 0 } + : animationMode === 'reduced' + ? { duration: .08, ease: 'linear' as const } + : { duration: .2, ease: 'easeOut' as const } + ), + [animationMode], + ) + + useEffect (() => { + applyClientAppearance () + + const mediaQuery = window.matchMedia ('(prefers-color-scheme: dark)') + const handleThemeChange = () => { + if (getClientThemeMode () === 'system') + applyClientAppearance () + } + + mediaQuery.addEventListener ('change', handleThemeChange) + return () => mediaQuery.removeEventListener ('change', handleThemeChange) + }, []) + + useEffect (() => { + applyClientAnimationMode (animationMode) + }, [animationMode]) useEffect (() => { const createUser = async () => { @@ -137,6 +222,30 @@ const App: FC = () => { createUser () }, []) + useEffect (() => { + if (!(user)) + return + + void (async () => { + try + { + const [settings, themeSlots] = await Promise.all ([ + fetchUserSettings (), + fetchUserThemeSlots (), + ]) + const storedThemeSlots = normaliseUserThemeSlots (themeSlots) + setCachedUserThemeSlots (storedThemeSlots) + if (!(hasStoredClientThemeSelection ())) + seedClientThemeMode (settings.theme) + applyClientAppearance () + } + catch + { + return + } + }) () + }, [user]) + switch (status) { case 503: @@ -150,17 +259,33 @@ const App: FC = () => { - - - - - - + + + + + + + + + + - + + + ) diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index 97e8248..f465bea 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -4,6 +4,8 @@ import { motion } from 'framer-motion' import { useRef } from 'react' import TagLink from '@/components/TagLink' +import { clientAnimationTransition } from '@/lib/clientAnimation' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { CSSProperties, FC, MutableRefObject } from 'react' @@ -20,6 +22,12 @@ type Props = { const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const dndId = `tag-node:${ pathKey }` const downPosRef = useRef<{ x: number; y: number } | null> (null) @@ -94,8 +102,10 @@ const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTa {...listeners}> + transition={{ layout: layoutTransition }} + layoutId={animationMode === 'off' + ? undefined + : `tag-${ sp ? 'sp-' : '' }${ tag.id }`}> ) diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 3cbbd00..9933ada 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -4,6 +4,8 @@ import YoutubeEmbed from 'react-youtube' import NicoViewer from '@/components/NicoViewer' import TwitterEmbed from '@/components/TwitterEmbed' import { useDialogue } from '@/components/dialogues/DialogueProvider' +import { Button } from '@/components/ui/button' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import type { FC, RefObject } from 'react' @@ -24,16 +26,19 @@ type Props = { onError?: (data: unknown) => void } -const PostEmbed: FC = ({ - ref, - post, - onLoadComplete, - onMetadataChange, - onVideoReady, - onPlaybackChange, - onError, -}) => { +const PostEmbed: FC = ( + { ref, + post, + onLoadComplete, + onMetadataChange, + onVideoReady, + onPlaybackChange, + onError }, +) => { const dialogue = useDialogue () + const behaviourSettings = useClientBehaviourSettings () + const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto' + const [manualLoadRequested, setManualLoadRequested] = useState (false) const [framed, setFramed] = useState (false) const [youtubePlayer, setYoutubePlayer] = useState (null) const niconicoVideoReadyRef = useRef (false) @@ -105,6 +110,10 @@ const PostEmbed: FC = ({ niconicoVideoReadyRef.current = false }, [post.url]) + useEffect (() => { + setManualLoadRequested (false) + }, [embedAutoLoadMode, post.url]) + useEffect (() => { if (!(youtubePlayer) || !(onPlaybackChange)) return @@ -117,6 +126,28 @@ const PostEmbed: FC = ({ }, [onPlaybackChange, reportYoutubePlayback, youtubePlayer]) const url = new URL (post.url) + const shouldLoadEmbed = + embedAutoLoadMode === 'auto' + || ( + embedAutoLoadMode === 'manual' + && manualLoadRequested + ) + + const externalLink = ( +
+ 外部ページを開く + ) + + const manualLoadControl = ( +
+ + {externalLink} +
) switch (url.hostname.split ('.').slice (-2).join ('.')) { @@ -128,6 +159,11 @@ const PostEmbed: FC = ({ const [videoId] = mVideoId + if (embedAutoLoadMode === 'off') + return externalLink + if (shouldLoadEmbed === false) + return manualLoadControl + return ( = ({ const [userId] = mUserId const [statusId] = mStatusId + if (embedAutoLoadMode === 'off') + return externalLink + if (shouldLoadEmbed === false) + return manualLoadControl + return } @@ -159,6 +200,11 @@ const PostEmbed: FC = ({ if (!(videoId)) break + if (embedAutoLoadMode === 'off') + return externalLink + if (shouldLoadEmbed === false) + return manualLoadControl + return ( = ({ height={360}/>) : (
+ {embedAutoLoadMode === 'off' + ? externalLink + : embedAutoLoadMode === 'manual' && shouldLoadEmbed === false + ? manualLoadControl + : ( { e.preventDefault () @@ -197,7 +248,7 @@ const PostEmbed: FC = ({ confirmText: '表示' })) }}> 外部ページを表示 - + )}
)} ) } diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index 19c1c6a..68bcdaa 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -68,9 +68,12 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { setBounds ({ start, end }) - const data = await apiGet ('/tags/autocomplete', { params: { q: token, nico: '0' } }) - setSuggestions (data.filter (t => t.postCount > 0)) - setSuggestionsVsbl (suggestions.length > 0) + const data = await apiGet ('/tags/autocomplete', { params: { + q: token, + nico: '0' } }) + const nextSuggestions = data.filter (t => t.postCount > 0) + setSuggestions (nextSuggestions) + setSuggestionsVsbl (nextSuggestions.length > 0) } return ( diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 0fe33b8..8d50c66 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -3,6 +3,7 @@ import { useRef } from 'react' import { useLocation } from 'react-router-dom' import PrefetchLink from '@/components/PrefetchLink' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' @@ -16,6 +17,15 @@ type Props = { posts: Post[] const PostList: FC = ({ posts, onClick }) => { const location = useLocation () + const behaviourSettings = useClientBehaviourSettings () + const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal' + const animationMode = behaviourSettings.animation ?? 'normal' + const cardLayoutTransition = + animationMode === 'off' + ? { duration: 0 } + : animationMode === 'reduced' + ? { duration: .08, ease: 'linear' as const } + : { duration: .2, ease: 'easeOut' as const } const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) @@ -39,7 +49,8 @@ const PostList: FC = ({ posts, onClick }) => { }}> 0 && 'ring-4 ring-green-500', @@ -54,22 +65,36 @@ const PostList: FC = ({ posts, onClick }) => { }} onLayoutAnimationComplete={() => { if (!(cardRef.current)) - return + return cardRef.current.style.zIndex = '' cardRef.current.style.position = '' }} - transition={{ layout: { duration: .2, ease: 'easeOut' } }}> - {post.title + transition={{ layout: cardLayoutTransition }}> + {thumbnailMode === 'off' + ? ( +
+ サムネイルなし +
) + : ( + {post.title)}
) })} ) } -export default PostList \ No newline at end of file +export default PostList diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 18fc855..3651b5b 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -5,6 +5,8 @@ import { createPath, useNavigate } from 'react-router-dom' import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' +import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard' import { cn } from '@/lib/utils' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' @@ -33,10 +35,20 @@ export default forwardRef (({ const navigate = useNavigate () const qc = useQueryClient () + const behaviourSettings = useClientBehaviourSettings () + const { confirmDiscardNavigation } = useUnsavedChangesGuard () + const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' + const path = useMemo ( + () => typeof to === 'string' ? to : createPath (to), + [to], + ) const url = useMemo (() => { - const path = (typeof to === 'string') ? to : createPath (to) - return (new URL (path, location.origin)).toString () - }, [to]) + return (new URL (path, window.location.origin)).toString () + }, [path]) + const nextPathname = useMemo ( + () => (new URL (path, window.location.origin)).pathname, + [path], + ) const setOverlay = useOverlayStore (s => s.setActive) const doPrefetch = async () => { @@ -54,11 +66,15 @@ export default forwardRef (({ const handleMouseEnter = async (ev: MouseEvent) => { onMouseEnter?.(ev) + if (ev.defaultPrevented || linkPreloadMode !== 'intent') + return await doPrefetch () } const handleTouchStart = async (ev: TouchEvent) => { onTouchStart?.(ev) + if (ev.defaultPrevented || linkPreloadMode !== 'intent') + return await doPrefetch () } @@ -77,6 +93,13 @@ export default forwardRef (({ ev.preventDefault () + if (nextPathname !== window.location.pathname) + { + const confirmed = await confirmDiscardNavigation () + if (!(confirmed)) + return + } + flushSync (() => { setOverlay (true) }) @@ -99,7 +122,7 @@ export default forwardRef (({ return ( = ( { text, className, truncateOnMobile = false, title }, ) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const outerRef = useRef (null) const staticRef = useRef (null) const animatedRef = useRef (null) @@ -98,6 +101,7 @@ const ResponsiveMarqueeText: FC = ( const animated = animatedRef.current const canMarquee = ( active + && animationMode !== 'off' && desktopMarqueeEnabled && overflowPx >= MIN_MARQUEE_OVERFLOW_PX && animated != null) @@ -207,7 +211,7 @@ const ResponsiveMarqueeText: FC = ( resetAnimated () setMarqueeVisible (false) } - }, [active, desktopMarqueeEnabled, overflowPx, text]) + }, [active, animationMode, desktopMarqueeEnabled, overflowPx, text]) return ( { } +const buildFlatTagByCategory = ( + byCategory: TagByCategory, +): TagByCategory => { + const tagsTmp = { } as TagByCategory + const seen = new Set () + + for (const category of CATEGORIES) + tagsTmp[category] = [] + + const visit = (tag: TagWithSections) => { + if (seen.has (tag.id)) + return + + seen.add (tag.id) + tagsTmp[tag.category].push ({ ...tag, children: [] }) + + for (const child of tag.children ?? []) + visit (child) + } + + for (const category of CATEGORIES) + for (const tag of byCategory[category] ?? []) + visit (tag) + + for (const category of CATEGORIES) + tagsTmp[category].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1) + + return tagsTmp +} + + const changeCategory = async ( tagId: number, category: Category): Promise => { @@ -156,6 +188,13 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { sp = Boolean (sp) const qc = useQueryClient () + const behaviourSettings = useClientBehaviourSettings () + const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped' + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const baseTags = useMemo (() => { const tagsTmp = { } as TagByCategory @@ -177,6 +216,10 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { const [dragging, setDragging] = useState (false) const [saving, setSaving] = useState (false) const [tags, setTags] = useState (baseTags) + const flatTagsByCategory = useMemo ( + () => buildFlatTagByCategory (tags), + [tags], + ) const suppressClickRef = useRef (false) @@ -310,26 +353,58 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { document.body.style.userSelect = '' }} modifiers={[restrictToWindowEdges]}> - {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( -
- - - {CATEGORY_NAMES[cat]} - - + {CATEGORIES.map ((cat: Category) => { + const categoryTags = + tagRelationDisplay === 'grouped' + ? (tags[cat] ?? []) + : (flatTagsByCategory[cat] ?? []) -
    - {(tags[cat] ?? []).flatMap (tag => ( - renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))} - -
-
))} + if (!(categoryTags.length > 0 || dragging)) + return null + + return ( +
+ + + {CATEGORY_NAMES[cat]} + + + +
    + {(tagRelationDisplay === 'grouped' + ? (tags[cat] ?? []).flatMap (tag => + renderTagTree ( + tag, + 0, + `cat-${ cat }`, + suppressClickRef, + undefined, + sp, + ), + ) + : (flatTagsByCategory[cat] ?? []).map (tag => ( +
  • + +
  • )))} + +
+
) + })} {post && ( + layoutId={animationMode === 'off' + ? undefined + : `post-info-${ sp }`} + transition={{ layout: layoutTransition }}> 情報
  • Id.: {post.id}
  • diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 180892b..615f7d1 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -1,9 +1,8 @@ import PrefetchLink from '@/components/PrefetchLink' import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText' -import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' import { cn } from '@/lib/utils' -import type { ComponentProps, FC, HTMLAttributes } from 'react' +import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react' import type { Tag } from '@/types' @@ -36,15 +35,16 @@ const TagLink: FC = ({ tag, withWiki = true, withCount = true, className, + style, title, ...props }) => { - const spanClass = cn ( - `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, - `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) - const linkClass = cn ( - spanClass, - `hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`, - `dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`) + const colourStyle = { + '--tag-link-colour': `var(--tag-colour-${ tag.category })`, + '--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`, + ...style, + } as CSSProperties + const spanClass = 'tag-link-colour' + const linkClass = 'tag-link-colour tag-link-hover-colour' const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom' const rootClass = 'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline' @@ -63,7 +63,8 @@ const TagLink: FC = ({ tag, ? ( + className={linkClass} + style={colourStyle}> ? ) : ( @@ -71,13 +72,15 @@ const TagLink: FC = ({ tag, ? ( + className={linkClass} + style={colourStyle}> ? ) : ( + className={linkClass} + style={colourStyle}> ? ))) : ( @@ -120,6 +123,7 @@ const TagLink: FC = ({ tag, = ({ tag, to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} title={textTitle} className={cn (linkClass, textClass, className)} + style={colourStyle} {...props}> = ({ tag, { return (
    = ({ posts, onClick }) => { const navigate = useNavigate () + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const animationsOff = animationMode === 'off' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const [tagsVsbl, setTagsVsbl] = useState (false) const [tags, setTags] = useState ({ }) @@ -65,12 +74,18 @@ const TagSidebar: FC = ({ posts, onClick }) => { {CATEGORIES.flatMap (cat => cat in tags ? ( tags[cat].map (tag => (
  • - - - + {animationsOff + ? ( +
    + +
    ) + : ( + + + )}
  • ))) : [])}
関聯 @@ -103,18 +118,25 @@ const TagSidebar: FC = ({ posts, onClick }) => { {posts.length > 0 && TagBlock} - - {tagsVsbl && ( - - {posts.length > 0 && TagBlock} - )} - + {animationsOff + ? ( + tagsVsbl && ( +
+ {posts.length > 0 && TagBlock} +
)) + : ( + + {tagsVsbl && ( + + {posts.length > 0 && TagBlock} + )} + )}
+ { if (moreVsbl) setMoreVsbl (false) }} - transition={{ layout: { duration: .2, ease: 'easeOut' } }} + transition={{ layout: topNavTransition }} onAnimationComplete={() => { measure (moreVsbl ? -1 : activeIdx) }}> @@ -282,12 +303,14 @@ const TopNav: FC = ({ user }) => {

{item.name}

@@ -298,30 +321,34 @@ const TopNav: FC = ({ user }) => { ? ( + exit: { y: -40, opacity: 0 } }))}> {subItem.component} ) : ( + exit: { y: -40, opacity: 0 } }))}> setMoreVsbl (false)} - className="h-full flex items-center px-3"> + className="top-nav-menu-link h-full flex items-center px-3"> {subItem.name} )))} @@ -330,129 +357,215 @@ const TopNav: FC = ({ user }) => { : ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0 && (
- - ({ y: d * 24, opacity: 0 }), - centre: { y: 0, opacity: 1 }, - exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }} - className="absolute inset-0 flex items-center px-3" - initial="enter" - animate="centre" - exit="exit" - transition={{ duration: .2, ease: 'easeOut' }}> - {(visibleMenu[activeIdx]?.subMenu ?? []) - .filter (item => item.visible ?? true) - .map ((item, i) => ( - 'component' in item - ? ( - - {item.component} - ) - : ( - - - {item.name} - - )))} - - + {animationsOff + ? ( +
+ {(visibleMenu[activeIdx]?.subMenu ?? []) + .filter (item => item.visible ?? true) + .map ((item, i) => ( + 'component' in item + ? ( +
+ {item.component} +
) + : ( +
+ + {item.name} + +
)))} +
) + : ( + + ({ y: d * 24, opacity: 0 }), + centre: { y: 0, opacity: 1 }, + exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }} + className="absolute inset-0 flex items-center px-3" + initial="enter" + animate="centre" + exit="exit" + transition={topNavTransition}> + {(visibleMenu[activeIdx]?.subMenu ?? []) + .filter (item => item.visible ?? true) + .map ((item, i) => ( + 'component' in item + ? ( + + {item.component} + ) + : ( + + + {item.name} + + )))} + + )}
))} - - {menuOpen && ( - - - {visibleMenu.map ((item, i) => ( - - ) => { - if (i !== openItemIdx) - { - ev.preventDefault () - setOpenItemIdx (i) - } - }}> - {item.name} - + {animationsOff + ? ( + menuOpen && ( +
+ + {visibleMenu.map ((item, i) => ( + + ) => { + if (i !== openItemIdx) + { + ev.preventDefault () + setOpenItemIdx (i) + } + }}> + {item.name} + - - {i === openItemIdx && ( - - {item.subMenu - .filter (subItem => subItem.visible ?? true) - .map ((subItem, j) => ( - 'component' in subItem - ? ( - - {subItem.component} - ) - : ( - - {subItem.name} - )))} - )} - - ))} - { - itemsRef.current[visibleMenu.length] = el - }} - className={cn ('w-full min-h-[40px] flex items-center pl-8', - ((openItemIdx < 0) - && 'font-bold bg-yellow-50 dark:bg-red-950'))}> - その他 » - - - - )} - + {i === openItemIdx && ( +
+ {item.subMenu + .filter (subItem => subItem.visible ?? true) + .map ((subItem, j) => ( + 'component' in subItem + ? ( + + {subItem.component} + ) + : ( + + {subItem.name} + )))} +
)} + ))} + { + itemsRef.current[visibleMenu.length] = el + }} + className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8', + ((openItemIdx < 0) + && 'top-nav-mobile-active font-bold'))}> + その他 » + + + +
)) + : ( + + {menuOpen && ( + + + {visibleMenu.map ((item, i) => ( + + ) => { + if (i !== openItemIdx) + { + ev.preventDefault () + setOpenItemIdx (i) + } + }}> + {item.name} + + + + {i === openItemIdx && ( + + {item.subMenu + .filter (subItem => subItem.visible ?? true) + .map ((subItem, j) => ( + 'component' in subItem + ? ( + + {subItem.component} + ) + : ( + + {subItem.name} + )))} + )} + + ))} + { + itemsRef.current[visibleMenu.length] = el + }} + className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8', + ((openItemIdx < 0) + && 'top-nav-mobile-active font-bold'))}> + その他 » + + + + )} + )} ) } diff --git a/frontend/src/components/common/TabGroup.test.tsx b/frontend/src/components/common/TabGroup.test.tsx index 424be1c..ae5cdd6 100644 --- a/frontend/src/components/common/TabGroup.test.tsx +++ b/frontend/src/components/common/TabGroup.test.tsx @@ -20,4 +20,16 @@ describe ('TabGroup', () => { expect (screen.getByText ('Alpha')).toBeInTheDocument () expect (screen.queryByText ('Beta')).not.toBeInTheDocument () }) + + it ('renders tabs as buttons and shows the right addon', () => { + render ( + 件数 20}> + Plaza + , + ) + + expect (screen.getByRole ('button', { name: '広場' })).toBeInTheDocument () + expect (screen.queryByRole ('link', { name: '広場' })).not.toBeInTheDocument () + expect (screen.getByText ('件数 20')).toBeInTheDocument () + }) }) diff --git a/frontend/src/components/common/TabGroup.tsx b/frontend/src/components/common/TabGroup.tsx index 0916c90..128e2dc 100644 --- a/frontend/src/components/common/TabGroup.tsx +++ b/frontend/src/components/common/TabGroup.tsx @@ -7,12 +7,15 @@ type TabProps = { name: string init?: boolean children: React.ReactNode } -type Props = { children: React.ReactNode } +type Props = { + children: React.ReactNode + rightAddon?: React.ReactNode +} export const Tab = ({ children }: TabProps) => <>{children} -const TabGroup: FC = ({ children }) => { +const TabGroup: FC = ({ children, rightAddon }) => { const tabs = React.Children.toArray (children) as React.ReactElement[] const [current, setCurrent] = useState (() => { @@ -22,17 +25,27 @@ const TabGroup: FC = ({ children }) => { return (
-
- {tabs.map ((tab, i) => ( - { - ev.preventDefault () - setCurrent (i) - }}> - {tab.props.name} - ))} +
+
+ {tabs.map ((tab, i) => ( + ))} +
+ {rightAddon && ( +
+ {rightAddon} +
)}
{tabs[current]} diff --git a/frontend/src/components/common/TagInput.test.tsx b/frontend/src/components/common/TagInput.test.tsx index dffc950..96eabf8 100644 --- a/frontend/src/components/common/TagInput.test.tsx +++ b/frontend/src/components/common/TagInput.test.tsx @@ -26,7 +26,7 @@ describe ('TagInput', () => { await waitFor (() => { expect (api.apiGet).toHaveBeenCalledWith ( '/tags/autocomplete', - { params: { q: '虹夏' } }, + { params: { q: '虹夏', nico: '0' } }, ) }) expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') @@ -41,4 +41,20 @@ describe ('TagInput', () => { expect (api.apiGet).not.toHaveBeenCalled () expect (setValue).toHaveBeenCalledWith (' ') }) + + it ('sends nico=1 only when includeNico is true', async () => { + const setValue = vi.fn () + api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })]) + + render () + + fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } }) + + await waitFor (() => { + expect (api.apiGet).toHaveBeenCalledWith ( + '/tags/autocomplete', + { params: { q: '虹夏', nico: '1' } }, + ) + }) + }) }) diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx index d0d6491..8eec0b6 100644 --- a/frontend/src/components/common/TagInput.tsx +++ b/frontend/src/components/common/TagInput.tsx @@ -10,13 +10,20 @@ import type { Tag } from '@/types' type Props = { + includeNico?: boolean describedBy?: string invalid?: boolean value: string setValue: (value: string) => void } -const TagInput: FC = ({ describedBy, invalid, value, setValue }) => { +const TagInput: FC = ({ + includeNico = false, + describedBy, + invalid, + value, + setValue, +}) => { const [activeIndex, setActiveIndex] = useState (-1) const [suggestions, setSuggestions] = useState ([]) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) @@ -32,10 +39,12 @@ const TagInput: FC = ({ describedBy, invalid, value, setValue }) => { return } - const data = await apiGet ('/tags/autocomplete', { params: { q } }) - setSuggestions (data.filter (t => t.postCount > 0)) - if (suggestions.length > 0) - setSuggestionsVsbl (true) + const data = await apiGet ('/tags/autocomplete', { params: { + q, + nico: includeNico ? '1' : '0' } }) + const nextSuggestions = data.filter (t => t.postCount > 0) + setSuggestions (nextSuggestions) + setSuggestionsVsbl (nextSuggestions.length > 0) } // TODO: TagSearch からのコピペのため,共通化を考へる. diff --git a/frontend/src/components/layout/MainArea.tsx b/frontend/src/components/layout/MainArea.tsx index 72ab7e2..268f015 100644 --- a/frontend/src/components/layout/MainArea.tsx +++ b/frontend/src/components/layout/MainArea.tsx @@ -1,5 +1,7 @@ import { motion } from 'framer-motion' +import { clientAnimationTransition } from '@/lib/clientAnimation' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { FC, ReactNode } from 'react' @@ -9,12 +11,21 @@ type Props = { className?: string } -const MainArea: FC = ({ children, className }) => ( - - {children} - ) +const MainArea: FC = ({ children, className }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) -export default MainArea \ No newline at end of file + return ( + + {children} + ) +} + +export default MainArea diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index ea4b015..574f924 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -1,6 +1,8 @@ import { motion } from 'framer-motion' import { Helmet } from 'react-helmet-async' +import { clientAnimationTransition } from '@/lib/clientAnimation' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react' @@ -68,6 +70,12 @@ const SidebarComponent: FC = ({ sidebarKey, side = 'left', }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const rootRef = useRef (null) const maxWidthRef = useRef (maxWidth) const getContainerWidth = useCallback (() => { @@ -305,8 +313,8 @@ const SidebarComponent: FC = ({ return ( void, + ) => void } + +type SegmentedOption = { + value: T + label: string } + +type SegmentedControlProps = { + value: T + options: SegmentedOption[] + onChange: (value: T) => void } + +type SettingBlockProps = { + title: string + description?: string + children: ReactNode } + +const animationOptions: SegmentedOption[] = [ + { value: 'off', label: 'なし' }, + { value: 'reduced', label: '控えめ' }, + { value: 'normal', label: '通常' }] + +const embedAutoLoadOptions: SegmentedOption[] = [ + { value: 'off', label: '読み込まない' }, + { value: 'manual', label: 'クリック時' }, + { value: 'auto', label: '自動' }] + +const linkPreloadOptions: SegmentedOption[] = [ + { value: 'off', label: 'しない' }, + { value: 'intent', label: 'する' }] + +const tagRelationDisplayOptions: SegmentedOption[] = [ + { value: 'flat', label: 'まとめない' }, + { value: 'grouped', label: 'まとめる' }] + +const SegmentedControl = ( + { value, options, onChange }: SegmentedControlProps, +) => { + return ( +
+ {options.map (option => ( + ))} +
) +} + + +const SettingBlock: FC = ( + { title, description, children }, +) => ( +
+
+

{title}

+

{description}

+
+ {children} +
) + + +const BehaviourSettingsSection: FC = ( + { sectionClassName, + onDirtyStateChange }, +) => { + const [savedSettings, setSavedSettings] = + useState (() => getClientBehaviorSettings ()) + const [draftSettings, setDraftSettings] = + useState (() => getClientBehaviorSettings ()) + + useEffect (() => { + const current = getClientBehaviorSettings () + setSavedSettings (current) + setDraftSettings (current) + }, []) + + const hasUnsavedChanges = useMemo ( + () => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings), + [draftSettings, savedSettings]) + + const updateDraft = ( + key: Key, + value: ClientBehaviorSettings[Key], + ) => { + setDraftSettings (current => ({ ...current, [key]: value })) + } + + const handleSave = () => { + const next = setClientBehaviorSettings (draftSettings) + setSavedSettings (next) + setDraftSettings (next) + toast ({ title: '動作設定を保存しました' }) + } + + const handleDiscard = () => { + setDraftSettings (savedSettings) + } + + useEffect (() => { + onDirtyStateChange?.(hasUnsavedChanges, handleDiscard) + + return () => { + onDirtyStateChange?.(false, () => { }) + } + }, [handleDiscard, hasUnsavedChanges, onDirtyStateChange]) + + return ( +
+
+
+

動作

+
+ +
+ + +
+
+ +
+ + updateDraft ('animation', value)}/> + + + + updateDraft ('linkPreload', value)}/> + + + + updateDraft ('tagRelationDisplay', value)}/> + + + + updateDraft ('embedAutoLoad', value)}/> + +
+
) +} + +export default BehaviourSettingsSection diff --git a/frontend/src/components/users/KeyboardSettingsSection.tsx b/frontend/src/components/users/KeyboardSettingsSection.tsx new file mode 100644 index 0000000..dc56cf6 --- /dev/null +++ b/frontend/src/components/users/KeyboardSettingsSection.tsx @@ -0,0 +1,262 @@ +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' +import { + getConflictingShortcutActionIds, + formatKeyBinding, + isSpaceBinding, + keyBindingFromKeyboardEvent, + SHORTCUT_DEFINITIONS, + SHORTCUT_SCOPE_LABELS, +} from '@/lib/keyboardShortcuts' +import { getEffectiveKeyBindings } from '@/lib/settings' +import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' + +import type { ShortcutActionId } from '@/lib/keyboardShortcuts' +import type { ClientKeyboardSettings } from '@/lib/settings' +import type { FC } from 'react' + +type Props = { + sectionClassName: string + onDirtyStateChange?: ( + dirty: boolean, + discard: () => void, + ) => void +} + + +const KeyboardSettingsSection: FC = ( + { sectionClassName, + onDirtyStateChange }, +) => { + const { + keyboardSettings, + saveKeyboardSettings, + } = useKeyboardShortcutSettings () + const [capturingActionId, setCapturingActionId] = + useState (null) + const [draftKeyboardSettings, setDraftKeyboardSettings] = + useState (keyboardSettings) + + useEffect (() => { + setDraftKeyboardSettings (keyboardSettings) + }, [keyboardSettings]) + + const draftEffectiveBindings = useMemo ( + () => getEffectiveKeyBindings (draftKeyboardSettings.bindings), + [draftKeyboardSettings.bindings], + ) + const draftConflictActionIds = useMemo ( + () => getConflictingShortcutActionIds (draftEffectiveBindings), + [draftEffectiveBindings], + ) + const hasUnsavedChanges = useMemo ( + () => JSON.stringify (draftKeyboardSettings) !== JSON.stringify (keyboardSettings), + [draftKeyboardSettings, keyboardSettings], + ) + + useEffect (() => { + if (capturingActionId == null) + return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing || event.key === 'Process') + return + + event.preventDefault () + event.stopPropagation () + + if (event.key === 'Escape') + { + setCapturingActionId (null) + return + } + + if (event.key === 'Backspace' || event.key === 'Delete') + { + setDraftKeyboardSettings (current => ({ + ...current, + bindings: { + ...(current.bindings ?? { }), + [capturingActionId]: null, + }, + })) + setCapturingActionId (null) + return + } + + const binding = keyBindingFromKeyboardEvent (event) + if (binding == null) + return + + if (isSpaceBinding (binding)) + { + toast ({ title: 'Space は今は割り当てられません.' }) + return + } + + setDraftKeyboardSettings (current => ({ + ...current, + bindings: { + ...(current.bindings ?? { }), + [capturingActionId]: binding, + }, + })) + setCapturingActionId (null) + } + + document.addEventListener ('keydown', onKeyDown, true) + return () => document.removeEventListener ('keydown', onKeyDown, true) + }, [capturingActionId]) + + const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({ + ...definition, + currentBinding: draftEffectiveBindings[definition.id], + hasConflict: draftConflictActionIds.has (definition.id), + hasCustomBinding: Object.prototype.hasOwnProperty.call ( + draftKeyboardSettings.bindings ?? { }, + definition.id, + ), + })), [ + draftConflictActionIds, + draftEffectiveBindings, + draftKeyboardSettings.bindings, + ]) + + const handleSave = () => { + saveKeyboardSettings (draftKeyboardSettings) + toast ({ title: 'キーボード設定を保存しました' }) + } + + const handleDiscard = () => { + setDraftKeyboardSettings (keyboardSettings) + setCapturingActionId (null) + } + + useEffect (() => { + onDirtyStateChange?.(hasUnsavedChanges, handleDiscard) + + return () => { + onDirtyStateChange?.(false, () => { }) + } + }, [handleDiscard, hasUnsavedChanges, onDirtyStateChange]) + + return ( +
+
+
+

キーボード

+
+ +
+ + + +
+
+ + + + {capturingActionId != null && ( +

+ キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます. +

)} + +
+ + + + + + + + + + + + + {rows.map (row => ( + + + + + + + + ))} + +
操作適用範囲現在のキー競合状態
{row.label}{SHORTCUT_SCOPE_LABELS[row.scope]} + {formatKeyBinding (row.currentBinding)} + + + + + + {row.hasConflict + ? 競合あり + : 'なし'} +
+
+
) +} + +export default KeyboardSettingsSection diff --git a/frontend/src/components/users/ShortcutHelpDialog.tsx b/frontend/src/components/users/ShortcutHelpDialog.tsx new file mode 100644 index 0000000..f00b072 --- /dev/null +++ b/frontend/src/components/users/ShortcutHelpDialog.tsx @@ -0,0 +1,87 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + formatKeyBinding, + SHORTCUT_DEFINITIONS, + SHORTCUT_SCOPE_LABELS, +} from '@/lib/keyboardShortcuts' + +import type { + KeyBinding, + ShortcutActionId, + ShortcutScope, +} from '@/lib/keyboardShortcuts' +import type { FC } from 'react' + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + activeScope: ShortcutScope + effectiveBindings: Record + conflictActionIds: Set + availableActionIds: Set +} + + +const ShortcutHelpDialog: FC = ( + { + open, + onOpenChange, + activeScope, + effectiveBindings, + conflictActionIds, + availableActionIds, + }, +) => { + const rows = SHORTCUT_DEFINITIONS + .filter (definition => + definition.scope === 'global' || definition.scope === activeScope) + .filter (definition => availableActionIds.has (definition.id)) + + return ( + + + + キーボード・ショートカット + + この画面で利用できるショートカットを表示します。 + + + +
+ + + + + + + + + + + {rows.map (row => ( + + + + + + ))} + +
操作適用範囲キー競合
{row.label}{SHORTCUT_SCOPE_LABELS[row.scope]} + {formatKeyBinding (effectiveBindings[row.id])} + + {conflictActionIds.has (row.id) + ? 競合あり + : 'なし'} +
+
+
+
) +} + +export default ShortcutHelpDialog diff --git a/frontend/src/index.css b/frontend/src/index.css index e70403a..3423883 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,6 +10,10 @@ { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; @@ -29,12 +33,40 @@ --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; + --theme-link: 221.2 83.2% 53.3%; + --top-nav-root-bg-mobile: #fef08a; + --top-nav-root-bg-desktop: #fefce8; + --top-nav-active-bg: #fef08a; + --top-nav-submenu-bg: #fef08a; + --top-nav-mobile-menu-bg: #fef08a; + --top-nav-mobile-active-bg: #fefce8; + --top-nav-brand-link: #db2777; + --top-nav-menu-link: #1d4ed8; + + --tag-colour-deerjikist: #9f1239; + --tag-colour-deerjikist-hover: #b62a51; + --tag-colour-meme: #6b21a8; + --tag-colour-meme-hover: #8238bf; + --tag-colour-character: #4d7c0f; + --tag-colour-character-hover: #649326; + --tag-colour-general: #155e75; + --tag-colour-general-hover: #2c758c; + --tag-colour-material: #c2410c; + --tag-colour-material-hover: #d95823; + --tag-colour-meta: #a16207; + --tag-colour-meta-hover: #b8791e; + --tag-colour-nico: #4b5563; + --tag-colour-nico-hover: #626c7a; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; @@ -54,20 +86,28 @@ --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; + --top-nav-root-bg-mobile: #230505; + --top-nav-root-bg-desktop: #230505; + --top-nav-active-bg: #450a0a; + --top-nav-submenu-bg: #450a0a; + --top-nav-mobile-menu-bg: #230505; + --top-nav-mobile-active-bg: #450a0a; + --top-nav-brand-link: #f9a8d4; + --top-nav-menu-link: #93c5fd; } body { - @apply overflow-x-clip; + @apply overflow-x-clip bg-background text-foreground; } a { - @apply text-blue-700 dark:text-blue-300; + color: hsl(var(--theme-link)); } a:hover { - @apply text-blue-500 dark:text-blue-100; + color: color-mix(in oklab, hsl(var(--theme-link)), white 18%); } } @@ -78,8 +118,8 @@ font-weight: 400; color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color: hsl(var(--foreground)); + background-color: hsl(var(--background)); font-synthesis: none; text-rendering: optimizeLegibility; @@ -87,6 +127,23 @@ -moz-osx-font-smoothing: grayscale; } +:root[data-animation='reduced'] *, +:root[data-animation='off'] * +{ + scroll-behavior: auto !important; +} + +:root[data-animation='off'] *, +:root[data-animation='off'] *::before, +:root[data-animation='off'] *::after +{ + animation-duration: 0ms !important; + animation-delay: 0ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0ms !important; + transition-delay: 0ms !important; +} + a { font-weight: 500; @@ -97,28 +154,15 @@ body { margin: 0; min-width: 320px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); } #root { min-height: 100dvh; -} - -@media (prefers-color-scheme: light) -{ - :root - { - color: #213547; - background-color: #ffffff; - } - a:hover - { - color: #747bff; - } - button - { - background-color: #f9f9f9; - } + background: hsl(var(--background)); + color: hsl(var(--foreground)); } @keyframes wiki-blink @@ -138,6 +182,73 @@ body position: relative; } +.tag-link-colour +{ + color: var(--tag-link-colour); +} + +.tag-link-hover-colour:hover +{ + color: var(--tag-link-hover-colour) !important; +} + +.top-nav-root +{ + background: var(--top-nav-root-bg-mobile); +} + +@media (min-width: 768px) +{ + .top-nav-root + { + background: var(--top-nav-root-bg-desktop); + } +} + +.top-nav-brand-link, +.top-nav-mobile-toggle +{ + color: var(--top-nav-brand-link); +} + +.top-nav-brand-link:hover, +.top-nav-mobile-toggle:hover +{ + color: color-mix(in oklab, var(--top-nav-brand-link), white 18%); +} + +.top-nav-menu-link, +.top-nav-mobile-row +{ + color: var(--top-nav-menu-link); +} + +.top-nav-menu-link:hover, +.top-nav-mobile-row:hover +{ + color: color-mix(in oklab, var(--top-nav-menu-link), white 18%); +} + +.top-nav-highlight +{ + background: var(--top-nav-active-bg); +} + +.top-nav-submenu +{ + background: var(--top-nav-submenu-bg); +} + +.top-nav-mobile-menu +{ + background: var(--top-nav-mobile-menu-bg); +} + +.top-nav-mobile-active +{ + background: var(--top-nav-mobile-active-bg); +} + .tag-marquee__animated { position: absolute; diff --git a/frontend/src/lib/clientAnimation.ts b/frontend/src/lib/clientAnimation.ts new file mode 100644 index 0000000..ebc77e8 --- /dev/null +++ b/frontend/src/lib/clientAnimation.ts @@ -0,0 +1,34 @@ +import type { Transition } from 'framer-motion' + +import type { ClientAnimationMode } from '@/lib/settings' + +type ClientAnimationTransitionOptions = { + normal: Transition + reduced?: Transition + off?: Transition } + + +export const clientAnimationsOff = ( + animationMode: ClientAnimationMode, +): boolean => + animationMode === 'off' + + +export const clientAnimationTransition = ( + animationMode: ClientAnimationMode, + { normal, reduced, off }: ClientAnimationTransitionOptions, +): Transition => { + if (animationMode === 'off') + return off ?? { duration: 0 } + + if (animationMode === 'reduced') + return reduced ?? { duration: .08, ease: 'linear' as const } + + return normal +} + + +export const clientScrollBehaviour = ( + animationMode: ClientAnimationMode, +): ScrollBehavior => + animationMode === 'off' ? 'auto' : 'smooth' diff --git a/frontend/src/lib/keyboardShortcuts.test.ts b/frontend/src/lib/keyboardShortcuts.test.ts new file mode 100644 index 0000000..a436fad --- /dev/null +++ b/frontend/src/lib/keyboardShortcuts.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_KEY_BINDINGS, + formatKeyBinding, + getConflictingShortcutActionIds, + isEditableEventTarget, + keyBindingFromKeyboardEvent, + resolveShortcutScope, + SHORTCUT_DEFINITIONS_BY_ID } from '@/lib/keyboardShortcuts' + +describe ('keyboardShortcuts', () => { + it ('defines the default key bindings used by the settings screen', () => { + expect (SHORTCUT_DEFINITIONS_BY_ID['global.openShortcutHelp']).toMatchObject ({ + label: 'ショートカット一覧を開く', + scope: 'global', + defaultBinding: { key: '?', shift: true } }) + expect (DEFAULT_KEY_BINDINGS['global.focusSearch']).toEqual ({ key: '/' }) + expect (DEFAULT_KEY_BINDINGS['navigation.posts']).toEqual ({ key: 'p' }) + expect (DEFAULT_KEY_BINDINGS['pagination.next']).toEqual ({ key: 'ArrowRight' }) + expect (DEFAULT_KEY_BINDINGS['settings.theme']).toEqual ({ key: '3' }) + }) + + it ('formats key bindings for display', () => { + expect (formatKeyBinding ({ key: '?', shift: true })).toBe ('Shift + ?') + expect (formatKeyBinding ({ key: '/' })).toBe ('/') + expect (formatKeyBinding ({ key: 'p' })).toBe ('P') + expect (formatKeyBinding ({ key: 'ArrowRight' })).toBe ('ArrowRight') + expect (formatKeyBinding (null)).toBe ('未割当') + }) + + it ('normalises keyboard events without assigning space by default', () => { + const event = { key: 'P', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false } as KeyboardEvent + + expect (keyBindingFromKeyboardEvent (event)).toEqual ({ key: 'p' }) + expect (DEFAULT_KEY_BINDINGS['pagination.next']).not.toEqual ({ key: ' ' }) + }) + + it ('detects conflicting action bindings', () => { + const conflicts = getConflictingShortcutActionIds ({ + ...DEFAULT_KEY_BINDINGS, + 'navigation.posts': { key: 'p' }, + 'navigation.tags': { key: 'p' } }) + + expect (conflicts.has ('navigation.posts')).toBe (true) + expect (conflicts.has ('navigation.tags')).toBe (true) + expect (conflicts.has ('navigation.wiki')).toBe (false) + }) + + it ('resolves route scopes and editable event targets', () => { + const input = document.createElement ('input') + const div = document.createElement ('div') + div.setAttribute ('role', 'textbox') + + expect (resolveShortcutScope ('/posts/search')).toBe ('postSearch') + expect (resolveShortcutScope ('/users/settings')).toBe ('settings') + expect (resolveShortcutScope ('/materials/1')).toBe ('materials') + expect (isEditableEventTarget (input)).toBe (true) + expect (isEditableEventTarget (div)).toBe (true) + expect (isEditableEventTarget (document.createElement ('button'))).toBe (false) + }) +}) diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts new file mode 100644 index 0000000..78be271 --- /dev/null +++ b/frontend/src/lib/keyboardShortcuts.ts @@ -0,0 +1,328 @@ +export type ShortcutScope = + | 'global' + | 'posts' + | 'postSearch' + | 'tags' + | 'settings' + | 'wiki' + | 'materials' + +export type ShortcutActionId = + | 'global.openShortcutHelp' + | 'global.focusSearch' + | 'navigation.posts' + | 'navigation.tags' + | 'navigation.materials' + | 'navigation.wiki' + | 'pagination.next' + | 'pagination.previous' + | 'settings.account' + | 'settings.behavior' + | 'settings.theme' + | 'settings.keyboard' + +export type KeyBinding = { + key: string + ctrl?: boolean + shift?: boolean + alt?: boolean + meta?: boolean +} + +export type ShortcutDefinition = { + id: ShortcutActionId + label: string + scope: ShortcutScope + defaultBinding: KeyBinding | null +} + +export const SHORTCUT_SCOPE_LABELS: Record = { + global: '全画面', + posts: '広場', + postSearch: '広場検索', + tags: 'タグ', + settings: '設定', + wiki: 'Wiki', + materials: '素材', +} + +export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ + { + id: 'global.openShortcutHelp', + label: 'ショートカット一覧を開く', + scope: 'global', + defaultBinding: { key: '?', shift: true }, + }, + { + id: 'global.focusSearch', + label: '検索欄へ移動', + scope: 'global', + defaultBinding: { key: '/' }, + }, + { + id: 'navigation.posts', + label: '広場へ移動', + scope: 'global', + defaultBinding: { key: 'p' }, + }, + { + id: 'navigation.tags', + label: 'タグへ移動', + scope: 'global', + defaultBinding: { key: 't' }, + }, + { + id: 'navigation.materials', + label: '素材へ移動', + scope: 'global', + defaultBinding: { key: 'm' }, + }, + { + id: 'navigation.wiki', + label: 'Wiki へ移動', + scope: 'global', + defaultBinding: { key: 'w' }, + }, + { + id: 'pagination.next', + label: '次のページへ移動', + scope: 'global', + defaultBinding: { key: 'ArrowRight' }, + }, + { + id: 'pagination.previous', + label: '前のページへ移動', + scope: 'global', + defaultBinding: { key: 'ArrowLeft' }, + }, + { + id: 'settings.account', + label: '設定: アカウント', + scope: 'settings', + defaultBinding: { key: '1' }, + }, + { + id: 'settings.behavior', + label: '設定: 動作', + scope: 'settings', + defaultBinding: { key: '2' }, + }, + { + id: 'settings.theme', + label: '設定: テーマ', + scope: 'settings', + defaultBinding: { key: '3' }, + }, + { + id: 'settings.keyboard', + label: '設定: キーボード', + scope: 'settings', + defaultBinding: { key: '4' }, + }, +] + +export const SHORTCUT_DEFINITIONS_BY_ID = + SHORTCUT_DEFINITIONS.reduce> ( + (definitions, definition) => { + definitions[definition.id] = definition + return definitions + }, + { + 'global.openShortcutHelp': SHORTCUT_DEFINITIONS[0], + 'global.focusSearch': SHORTCUT_DEFINITIONS[1], + 'navigation.posts': SHORTCUT_DEFINITIONS[2], + 'navigation.tags': SHORTCUT_DEFINITIONS[3], + 'navigation.materials': SHORTCUT_DEFINITIONS[4], + 'navigation.wiki': SHORTCUT_DEFINITIONS[5], + 'pagination.next': SHORTCUT_DEFINITIONS[6], + 'pagination.previous': SHORTCUT_DEFINITIONS[7], + 'settings.account': SHORTCUT_DEFINITIONS[8], + 'settings.behavior': SHORTCUT_DEFINITIONS[9], + 'settings.theme': SHORTCUT_DEFINITIONS[10], + 'settings.keyboard': SHORTCUT_DEFINITIONS[11], + }, + ) + +export const DEFAULT_KEY_BINDINGS = + SHORTCUT_DEFINITIONS.reduce> ( + (bindings, definition) => { + bindings[definition.id] = definition.defaultBinding + return bindings + }, + { + 'global.openShortcutHelp': null, + 'global.focusSearch': null, + 'navigation.posts': null, + 'navigation.tags': null, + 'navigation.materials': null, + 'navigation.wiki': null, + 'pagination.next': null, + 'pagination.previous': null, + 'settings.account': null, + 'settings.behavior': null, + 'settings.theme': null, + 'settings.keyboard': null, + }, + ) + +const normaliseKey = (key: string): string => { + if (key === 'Spacebar') + return ' ' + if (key.length === 1 && key !== ' ') + return key.toLowerCase () + return key +} + +const isModifierKey = (key: string): boolean => + key === 'Shift' + || key === 'Control' + || key === 'Alt' + || key === 'Meta' + +export const normaliseKeyBinding = (value: unknown): KeyBinding | null => { + if (!(value) || typeof value !== 'object') + return null + + const binding = value as Partial + if (typeof binding.key !== 'string') + return null + + const key = normaliseKey (binding.key) + if (key.length === 0 || isModifierKey (key)) + return null + + return { + key, + ...(binding.ctrl ? { ctrl: true } : { }), + ...(binding.shift ? { shift: true } : { }), + ...(binding.alt ? { alt: true } : { }), + ...(binding.meta ? { meta: true } : { }), + } +} + +export const serialiseKeyBinding = (binding: KeyBinding | null): string | null => { + if (binding == null) + return null + + const parts = [ + binding.ctrl ? 'Ctrl' : null, + binding.shift ? 'Shift' : null, + binding.alt ? 'Alt' : null, + binding.meta ? 'Meta' : null, + binding.key, + ].filter ((part): part is string => part != null) + + return parts.join ('+') +} + +const keyLabel = (key: string): string => { + switch (key) + { + case ' ': + return 'Space' + + case 'ArrowLeft': + return 'ArrowLeft' + + case 'ArrowRight': + return 'ArrowRight' + + case 'ArrowUp': + return 'ArrowUp' + + case 'ArrowDown': + return 'ArrowDown' + + case 'Escape': + return 'Escape' + + case 'Backspace': + return 'Backspace' + + case 'Delete': + return 'Delete' + + default: + return key.length === 1 ? key.toUpperCase () : key + } +} + +export const formatKeyBinding = (binding: KeyBinding | null): string => { + if (binding == null) + return '未割当' + + return [ + binding.ctrl ? 'Ctrl' : null, + binding.shift ? 'Shift' : null, + binding.alt ? 'Alt' : null, + binding.meta ? 'Meta' : null, + keyLabel (binding.key), + ].filter ((part): part is string => part != null) + .join (' + ') +} + +export const keyBindingFromKeyboardEvent = ( + event: Pick, +): KeyBinding | null => { + const key = normaliseKey (event.key) + if (key.length === 0 || isModifierKey (key)) + return null + + return { + key, + ...(event.ctrlKey ? { ctrl: true } : { }), + ...(event.shiftKey ? { shift: true } : { }), + ...(event.altKey ? { alt: true } : { }), + ...(event.metaKey ? { meta: true } : { }), + } +} + +export const isEditableEventTarget = (target: EventTarget | null): boolean => { + if (!(target instanceof Element)) + return false + + return target.closest ( + 'input, textarea, select, [contenteditable="true"], [role="textbox"]', + ) != null +} + +export const getConflictingShortcutActionIds = ( + bindings: Record, +): Set => { + const grouped = new Map () + + Object.entries (bindings).forEach (([actionId, binding]) => { + const serialised = serialiseKeyBinding (binding) + if (serialised == null) + return + + const ids = grouped.get (serialised) ?? [] + ids.push (actionId as ShortcutActionId) + grouped.set (serialised, ids) + }) + + return new Set ( + [...grouped.values ()] + .filter (actionIds => actionIds.length > 1) + .flat (), + ) +} + +export const resolveShortcutScope = (pathname: string): ShortcutScope => { + if (pathname.startsWith ('/posts/search')) + return 'postSearch' + if (pathname.startsWith ('/posts')) + return 'posts' + if (pathname.startsWith ('/tags')) + return 'tags' + if (pathname.startsWith ('/users/settings')) + return 'settings' + if (pathname.startsWith ('/wiki')) + return 'wiki' + if (pathname.startsWith ('/materials')) + return 'materials' + return 'global' +} + +export const isSpaceBinding = (binding: KeyBinding | null): boolean => + binding?.key === ' ' diff --git a/frontend/src/lib/settings.test.ts b/frontend/src/lib/settings.test.ts new file mode 100644 index 0000000..9ec1d66 --- /dev/null +++ b/frontend/src/lib/settings.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { applyClientAnimationMode, + applyThemeTokens, + buildThemeTokens, + CLIENT_SETTINGS_STORAGE_KEY, + DARK_THEME_TOKENS, + DEFAULT_POST_LIST_ORDER, + getClientListLimit, + getClientListOrder, + getClientThemeMode, + LIGHT_THEME_TOKENS, + loadClientSettings, + setClientListSettings, + setClientThemeAppearance } from '@/lib/settings' + +import type { FetchPostsOrder } from '@/types' + +const installMatchMedia = (matches: boolean) => { + Object.defineProperty (window, 'matchMedia', { + configurable: true, + value: vi.fn ().mockImplementation (() => ({ + matches, + addEventListener: vi.fn (), + removeEventListener: vi.fn () })) }) +} + +describe ('settings', () => { + beforeEach (() => { + localStorage.clear () + document.documentElement.removeAttribute ('data-animation') + document.documentElement.removeAttribute ('style') + document.documentElement.className = '' + installMatchMedia (false) + }) + + it ('stores list settings under the shared client settings key', () => { + setClientListSettings ('postSearch', { + limit: 50, + order: 'title:asc' }) + + expect (getClientListLimit ('postSearch')).toBe (50) + expect (getClientListOrder ('postSearch')).toBe ('title:asc') + expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) + .toMatchObject ({ lists: { postSearch: { limit: 50, order: 'title:asc' } } }) + }) + + it ('ignores invalid list limits and keeps stored order strings', () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { postList: { limit: 30, order: DEFAULT_POST_LIST_ORDER } } })) + + expect (getClientListLimit ('postList')).toBeNull () + expect (getClientListOrder ('postList')).toBe ( + DEFAULT_POST_LIST_ORDER) + }) + + it ('stores theme mode and light/dark slot selections in client settings', () => { + setClientThemeAppearance ({ + themeMode: 'dark', + activeLightThemeSlotNo: 2, + activeDarkThemeSlotNo: 3 }) + + expect (getClientThemeMode ()).toBe ('dark') + expect (loadClientSettings ().appearance).toMatchObject ({ + themeMode: 'dark', + activeLightThemeSlotNo: 2, + activeDarkThemeSlotNo: 3 }) + }) + + it ('applies animation mode to the document dataset', () => { + applyClientAnimationMode ('off') + expect (document.documentElement.dataset.animation).toBe ('off') + + applyClientAnimationMode ('reduced') + expect (document.documentElement.dataset.animation).toBe ('reduced') + + applyClientAnimationMode ('normal') + expect (document.documentElement.dataset.animation).toBeUndefined () + }) + + it ('normalises TopNav menu link tokens to complete CSS colours', () => { + const tokens = buildThemeTokens ('light', { + link: '221.2 83.2% 53.3%', + topNavMenuLink: '221.2 83.2% 53.3%' }) + + expect (tokens.link).toBe ('221.2 83.2% 53.3%') + expect (tokens.topNavMenuLink).toBe ('hsl(221.2 83.2% 53.3%)') + }) + + it ('derives TopNav colours without mixing mobile active backgrounds', () => { + expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe ( + LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop) + expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe ( + DARK_THEME_TOKENS.topNavActiveBackground) + }) + + it ('sets shadcn tokens and TopNav tokens with their different value formats', () => { + const tokens = buildThemeTokens ('dark', { link: '213.1 93.9% 67.8%' }) + + applyThemeTokens (tokens) + + expect (document.documentElement.style.getPropertyValue ('--theme-link')).toBe ( + '213.1 93.9% 67.8%') + expect (document.documentElement.style.getPropertyValue ('--top-nav-menu-link')).toBe ( + 'hsl(213.1 93.9% 67.8%)') + }) +}) diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts new file mode 100644 index 0000000..8b943e2 --- /dev/null +++ b/frontend/src/lib/settings.ts @@ -0,0 +1,1721 @@ +import { CATEGORIES } from '@/consts' +import { apiGet, apiPatch, apiPut } from '@/lib/api' +import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts' + +import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' +import type { KeyBinding, ShortcutActionId } from '@/lib/keyboardShortcuts' + +export type UserSettings = { + theme: 'system' | 'light' | 'dark' + autoFetchTitle: 'auto' | 'manual' | 'off' + autoFetchThumbnail: 'auto' | 'manual' | 'off' + wikiEditorMode: 'split' | 'write' | 'preview' } + +export type ThemeTagColours = Record +export type BuiltinThemeId = 'light' | 'dark' +export type UserThemeSlotNo = 1 | 2 | 3 +export type UserThemeSlotSelection = `user:${ BuiltinThemeId }:${ UserThemeSlotNo }` +export type ClientThemeMode = 'system' | 'light' | 'dark' +export type ThemeSlotSelection = + | 'system' + | `builtin:${ BuiltinThemeId }` + | UserThemeSlotSelection +export type ThemeId = BuiltinThemeId | `custom:${ string }` +export type ActiveThemeId = ThemeId | 'system' +export type ResolvedTheme = 'light' | 'dark' +export type ThemeTokens = { + background: string + foreground: string + card: string + cardForeground: string + popover: string + popoverForeground: string + primary: string + primaryForeground: string + secondary: string + secondaryForeground: string + muted: string + mutedForeground: string + accent: string + accentForeground: string + destructive: string + destructiveForeground: string + border: string + input: string + ring: string + link: string + topNavRootBackgroundMobile: string + topNavRootBackgroundDesktop: string + topNavActiveBackground: string + topNavSubmenuBackground: string + topNavMobileActiveBackground: string + topNavBrandLink: string + topNavMenuLink: string + tagColours: ThemeTagColours } +type EditableTopNavTokens = { + rootBackgroundMobile: string + rootBackgroundDesktop: string + activeBackground: string + brandLink: string } +type ResolvedTopNavTokens = { + rootBackgroundMobile: string + rootBackgroundDesktop: string + activeBackground: string + submenuBackground: string + mobileMenuBackground: string + mobileActiveBackground: string + brandLink: string + menuLink: string } +export type UserThemeSlot = { + baseTheme: BuiltinThemeId + slotNo: UserThemeSlotNo + tokens: ThemeTokens + createdAt?: string + updatedAt?: string } +export type UserThemeSlotMap = + Partial> +export type ResolvedThemeSelection = { + selection: ThemeSlotSelection + baseTheme: BuiltinThemeId + builtin: boolean + tokens: ThemeTokens } +export type ResolvedDisplayedTheme = { + themeMode: ClientThemeMode + baseTheme: BuiltinThemeId + selection: UserThemeSlotSelection + slotNo: UserThemeSlotNo + tokens: ThemeTokens } +export type ClientTheme = { + id: ThemeId + name: string + builtin: boolean + baseTheme: BuiltinThemeId + tokens: ThemeTokens } +export type CustomClientTheme = + ClientTheme & { builtin: false } + +export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' +export type TheatreTagFlow = 'vertical' | 'horizontal' +export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off' +export type ClientAnimationMode = 'normal' | 'reduced' | 'off' +export type ClientEmbedAutoLoadMode = 'auto' | 'manual' | 'off' +export type ClientLinkPreloadMode = 'off' | 'intent' +export type ClientTagRelationDisplayMode = 'flat' | 'grouped' +export type ClientThumbnailMode = 'normal' | 'light' | 'off' +export type ClientPaneBreakpoint = 'desktop' | 'tablet' +export type ClientListKey = 'postList' | 'postSearch' | 'tagList' +export type ClientListLimit = 20 | 50 | 100 + +type ClientPaneSettings = { + widthPxByBreakpoint?: Partial> + collapsed?: boolean } + +type ClientListSettings = { + limit?: ClientListLimit + order?: string } + +export type ClientKeyboardSettings = { + enabled?: boolean + bindings?: Partial> } + +export type ClientAppearanceSettings = { + themeMode?: ClientThemeMode + activeLightThemeSlotNo?: UserThemeSlotNo + activeDarkThemeSlotNo?: UserThemeSlotNo + activeThemeSlot?: ThemeSlotSelection + activeThemeId?: ActiveThemeId + customThemes?: Record + // Legacy fields kept for migration from the earlier appearance model. + theme?: UserSettings['theme'] + tagColours?: Partial } + +export type ClientBehaviorSettings = { + animation?: ClientAnimationMode + linkPreload?: ClientLinkPreloadMode + tagRelationDisplay?: ClientTagRelationDisplayMode + embedAutoLoad?: ClientEmbedAutoLoadMode + thumbnailMode?: ClientThumbnailMode } + +// 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 +// to the common settings UI yet. +export const DEFAULT_USER_SETTINGS: UserSettings = { + theme: 'system', + autoFetchTitle: 'manual', + autoFetchThumbnail: 'manual', + wikiEditorMode: 'split' } + +// Browser-local settings only. These are device or screen specific. +export type ClientSettings = { + appearance?: ClientAppearanceSettings + behavior?: ClientBehaviorSettings + keyboard?: ClientKeyboardSettings + panes?: Record + lists?: Partial> + theatre?: { layoutMode?: TheatreLayoutMode + tagFlow?: TheatreTagFlow } + gekanator?: { backgroundMotion?: GekanatorBackgroundMotionMode } + reducedMotion?: string + embedAutoLoad?: string + thumbnailMode?: string } + +export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings' +export const CLIENT_SETTINGS_EVENT = 'btrc-hub:client-settings-change' +export const USER_THEME_SLOT_NOS: UserThemeSlotNo[] = [1, 2, 3] +export const USER_THEME_SLOT_SELECTIONS: UserThemeSlotSelection[] = [ + 'user:light:1', + 'user:light:2', + 'user:light:3', + 'user:dark:1', + 'user:dark:2', + 'user:dark:3'] +export const THEME_OPTIONS = ['system', 'light', 'dark'] as const +export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const +export const POST_LIST_ORDER_OPTIONS = [ + 'title:asc', + 'title:desc', + 'url:asc', + 'url:desc', + 'original_created_at:asc', + 'original_created_at:desc', + 'created_at:asc', + 'created_at:desc', + 'updated_at:asc', + 'updated_at:desc'] as const satisfies readonly FetchPostsOrder[] +export const TAG_LIST_ORDER_OPTIONS = [ + 'name:asc', + 'name:desc', + 'category:asc', + 'category:desc', + 'post_count:asc', + 'post_count:desc', + 'created_at:asc', + 'created_at:desc', + 'updated_at:asc', + 'updated_at:desc'] as const satisfies readonly FetchTagsOrder[] +export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20 +export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc' +export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc' +export const DEFAULT_LIGHT_THEME_TAG_COLOURS: ThemeTagColours = { + deerjikist: '#9f1239', + meme: '#6b21a8', + character: '#4d7c0f', + general: '#155e75', + material: '#c2410c', + meta: '#a16207', + nico: '#4b5563' } +export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = { + deerjikist: '#fb7185', + meme: '#d8b4fe', + character: '#bef264', + general: '#67e8f9', + material: '#fdba74', + meta: '#fde047', + nico: '#e5e7eb' } +export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS +export const LIGHT_THEME_TOKENS: ThemeTokens = { + background: '0 0% 100%', + foreground: '222.2 84% 4.9%', + card: '0 0% 100%', + cardForeground: '222.2 84% 4.9%', + popover: '0 0% 100%', + popoverForeground: '222.2 84% 4.9%', + primary: '222.2 47.4% 11.2%', + primaryForeground: '210 40% 98%', + secondary: '210 40% 96.1%', + secondaryForeground: '222.2 47.4% 11.2%', + muted: '210 40% 96.1%', + mutedForeground: '215.4 16.3% 46.9%', + accent: '210 40% 96.1%', + accentForeground: '222.2 47.4% 11.2%', + destructive: '0 72.2% 50.6%', + destructiveForeground: '210 40% 98%', + border: '214.3 31.8% 91.4%', + input: '214.3 31.8% 91.4%', + ring: '222.2 84% 4.9%', + link: '221.2 83.2% 53.3%', + topNavRootBackgroundMobile: '#fef08a', + topNavRootBackgroundDesktop: '#fefce8', + topNavActiveBackground: '#fef08a', + topNavSubmenuBackground: '#fef08a', + topNavMobileActiveBackground: '#fefce8', + topNavBrandLink: '#db2777', + topNavMenuLink: '#1d4ed8', + tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS } +export const DARK_THEME_TOKENS: ThemeTokens = { + background: '222.2 84% 4.9%', + foreground: '210 40% 98%', + card: '222.2 84% 4.9%', + cardForeground: '210 40% 98%', + popover: '222.2 84% 4.9%', + popoverForeground: '210 40% 98%', + primary: '210 40% 98%', + primaryForeground: '222.2 47.4% 11.2%', + secondary: '217.2 32.6% 17.5%', + secondaryForeground: '210 40% 98%', + muted: '217.2 32.6% 17.5%', + mutedForeground: '215 20.2% 65.1%', + accent: '217.2 32.6% 17.5%', + accentForeground: '210 40% 98%', + destructive: '0 62.8% 45%', + destructiveForeground: '210 40% 98%', + border: '217.2 32.6% 17.5%', + input: '217.2 32.6% 17.5%', + ring: '212.7 26.8% 83.9%', + link: '213.1 93.9% 67.8%', + topNavRootBackgroundMobile: '#230505', + topNavRootBackgroundDesktop: '#230505', + topNavActiveBackground: '#450a0a', + topNavSubmenuBackground: '#450a0a', + topNavMobileActiveBackground: '#450a0a', + topNavBrandLink: '#f9a8d4', + topNavMenuLink: '#93c5fd', + tagColours: DEFAULT_DARK_THEME_TAG_COLOURS } +export const BUILTIN_THEMES: Record = { + light: { id: 'light', + name: 'ライト・モード', + builtin: true, + baseTheme: 'light', + tokens: LIGHT_THEME_TOKENS }, + dark: { id: 'dark', + name: 'ダーク・モード', + builtin: true, + baseTheme: 'dark', + tokens: DARK_THEME_TOKENS } } + +const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode' +const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' +const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY = 'gekanator:background-motion:v1' +const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated' + + +const createCustomThemeId = (): `custom:${ string }` => { + const randomPart = + globalThis.crypto?.randomUUID?.() + ?? `${ Date.now ().toString (36) }-${ Math.random ().toString (36).slice (2) }` + + return `custom:${ randomPart }` +} + + +export const fetchUserSettings = async (): Promise => { + const raw = await apiGet<{ + theme: UserSettings['theme'] + auto_fetch_title: UserSettings['autoFetchTitle'] + auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] + wiki_editor_mode: UserSettings['wikiEditorMode'] }> ('/users/settings') + + return { + theme: raw.theme, + autoFetchTitle: raw.auto_fetch_title, + autoFetchThumbnail: raw.auto_fetch_thumbnail, + wikiEditorMode: raw.wiki_editor_mode } +} + + +export const updateUserSettings = async ( + settings: Partial<{ theme: UserSettings['theme'] }>, +): Promise => { + const raw = await apiPatch<{ + theme: UserSettings['theme'] + auto_fetch_title: UserSettings['autoFetchTitle'] + auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] + wiki_editor_mode: UserSettings['wikiEditorMode'] }> ('/users/settings', settings) + + return { + theme: raw.theme, + autoFetchTitle: raw.auto_fetch_title, + autoFetchThumbnail: raw.auto_fetch_thumbnail, + wikiEditorMode: raw.wiki_editor_mode } +} + + +const safeParseClientSettings = (raw: string | null): ClientSettings => { + if (!(raw)) + return { } + + try + { + const parsed = JSON.parse (raw) + return parsed && typeof parsed === 'object' ? parsed as ClientSettings : { } + } + catch + { + return { } + } +} + + +export const loadClientSettings = (): ClientSettings => + safeParseClientSettings (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY)) + + +export const saveClientSettings = (settings: ClientSettings): void => { + localStorage.setItem (CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify (settings)) + + if (typeof window !== 'undefined') + { + window.dispatchEvent (new CustomEvent (CLIENT_SETTINGS_EVENT, { + detail: settings })) + } +} + + +export const updateClientSettings = ( + updater: (settings: ClientSettings) => ClientSettings, +): ClientSettings => { + const next = updater (loadClientSettings ()) + saveClientSettings (next) + return next +} + + +const validListLimit = (value: unknown): ClientListLimit | null => + value === 20 || value === 50 || value === 100 ? value : null + + +const normaliseThemeMode = (value: unknown): ClientThemeMode | null => + value === 'system' || value === 'light' || value === 'dark' ? value : null + + +const normaliseThemeSlotNo = (value: unknown): UserThemeSlotNo | null => + value === 1 || value === 2 || value === 3 ? value : null + + +const normaliseAnimationMode = (value: unknown): ClientAnimationMode | null => { + if (value === 'normal' || value === 'reduced' || value === 'off') + return value + if (value === 'true') + return 'reduced' + if (value === 'false') + return 'normal' + return null +} + + +const normaliseEmbedAutoLoadMode = (value: unknown): ClientEmbedAutoLoadMode | null => + value === 'auto' || value === 'manual' || value === 'off' ? value : null + + +const normaliseLinkPreloadMode = ( + value: unknown, +): ClientLinkPreloadMode | null => + value === 'off' || value === 'intent' ? value : null + + +const normaliseTagRelationDisplayMode = ( + value: unknown, +): ClientTagRelationDisplayMode | null => + value === 'flat' || value === 'grouped' ? value : null + + +const normaliseThumbnailMode = (value: unknown): ClientThumbnailMode | null => + value === 'normal' || value === 'light' || value === 'off' ? value : null + + +const normaliseKeyboardBindings = ( + bindings: unknown, +): Partial> => { + if (!(bindings) || typeof bindings !== 'object') + return { } + + return ( + Object.keys (DEFAULT_KEY_BINDINGS) + .reduce>> ((normalised, actionId) => { + const value = (bindings as Record)[actionId] + if (value === null) + { + normalised[actionId as ShortcutActionId] = null + return normalised + } + + const binding = normaliseKeyBinding (value) + if (binding != null) + normalised[actionId as ShortcutActionId] = binding + + return normalised + }, { })) +} + + +export const getClientKeyboardSettings = (): ClientKeyboardSettings => { + const keyboard = loadClientSettings ().keyboard + + return { + enabled: keyboard?.enabled !== false, + bindings: normaliseKeyboardBindings (keyboard?.bindings) } +} + + +const legacyAnimationMode = (): ClientAnimationMode | null => + normaliseAnimationMode (loadClientSettings ().reducedMotion) + + +const legacyEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode | null => + normaliseEmbedAutoLoadMode (loadClientSettings ().embedAutoLoad) + + +const legacyThumbnailMode = (): ClientThumbnailMode | null => + normaliseThumbnailMode (loadClientSettings ().thumbnailMode) + + +export const getClientAnimationMode = (): ClientAnimationMode => + loadClientSettings ().behavior?.animation + ?? legacyAnimationMode () + ?? 'normal' + + +export const applyClientAnimationMode = (mode: ClientAnimationMode): void => { + if (typeof document === 'undefined') + return + + if (mode === 'normal') + { + delete document.documentElement.dataset.animation + return + } + + document.documentElement.dataset.animation = mode +} + + +export const setClientAnimationMode = ( + animation: ClientAnimationMode, +): ClientBehaviorSettings => + setClientBehaviorSettings ({ ...getClientBehaviorSettings (), animation }) + + +export const getClientLinkPreloadMode = (): ClientLinkPreloadMode => + normaliseLinkPreloadMode (loadClientSettings ().behavior?.linkPreload) + ?? 'intent' + + +export const setClientLinkPreloadMode = ( + linkPreload: ClientLinkPreloadMode, +): ClientBehaviorSettings => + setClientBehaviorSettings ({ ...getClientBehaviorSettings (), linkPreload }) + + +export const getClientTagRelationDisplayMode = (): ClientTagRelationDisplayMode => + normaliseTagRelationDisplayMode (loadClientSettings ().behavior?.tagRelationDisplay) + ?? 'grouped' + + +export const setClientTagRelationDisplayMode = ( + tagRelationDisplay: ClientTagRelationDisplayMode, +): ClientBehaviorSettings => + setClientBehaviorSettings ({ ...getClientBehaviorSettings (), tagRelationDisplay }) + + +export const getClientEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode => + loadClientSettings ().behavior?.embedAutoLoad + ?? legacyEmbedAutoLoadMode () + ?? 'auto' + + +export const setClientEmbedAutoLoadMode = ( + embedAutoLoad: ClientEmbedAutoLoadMode, +): ClientBehaviorSettings => + setClientBehaviorSettings ({ ...getClientBehaviorSettings (), embedAutoLoad }) + + +export const getClientThumbnailMode = (): ClientThumbnailMode => + loadClientSettings ().behavior?.thumbnailMode + ?? legacyThumbnailMode () + ?? 'normal' + + +export const setClientThumbnailMode = ( + thumbnailMode: ClientThumbnailMode, +): ClientBehaviorSettings => + setClientBehaviorSettings ({ ...getClientBehaviorSettings (), thumbnailMode }) + + +export const getClientBehaviorSettings = (): ClientBehaviorSettings => ({ + animation: getClientAnimationMode (), + linkPreload: getClientLinkPreloadMode (), + tagRelationDisplay: getClientTagRelationDisplayMode (), + embedAutoLoad: getClientEmbedAutoLoadMode (), + thumbnailMode: getClientThumbnailMode () }) + + +export const setClientBehaviorSettings = ( + behavior: ClientBehaviorSettings, +): ClientBehaviorSettings => { + const next: ClientBehaviorSettings = { + animation: ( + normaliseAnimationMode (behavior.animation) + ?? getClientAnimationMode ()), + linkPreload: ( + normaliseLinkPreloadMode (behavior.linkPreload) + ?? getClientLinkPreloadMode ()), + tagRelationDisplay: ( + normaliseTagRelationDisplayMode (behavior.tagRelationDisplay) + ?? getClientTagRelationDisplayMode ()), + embedAutoLoad: ( + normaliseEmbedAutoLoadMode (behavior.embedAutoLoad) + ?? getClientEmbedAutoLoadMode ()), + thumbnailMode: ( + normaliseThumbnailMode (behavior.thumbnailMode) + ?? getClientThumbnailMode ()) } + + updateClientSettings (settings => ({ ...settings, behavior: next })) + applyClientAnimationMode (next.animation ?? 'normal') + return next +} + + +export const setClientKeyboardSettings = ( + keyboard: ClientKeyboardSettings, +): ClientKeyboardSettings => { + const next: ClientKeyboardSettings = { + enabled: keyboard.enabled !== false, + bindings: normaliseKeyboardBindings (keyboard.bindings) } + + updateClientSettings (settings => ({ ...settings, keyboard: next })) + + return next +} + + +export const getEffectiveKeyBindings = ( + bindings: Partial> = + getClientKeyboardSettings ().bindings ?? { }, +): Record => + Object.keys (DEFAULT_KEY_BINDINGS).reduce> ( + (effectiveBindings, actionId) => { + const key = actionId as ShortcutActionId + effectiveBindings[key] = + Object.prototype.hasOwnProperty.call (bindings, key) + ? bindings[key] ?? null + : DEFAULT_KEY_BINDINGS[key] + return effectiveBindings + }, + { ...DEFAULT_KEY_BINDINGS }) + + +export const setClientKeyBinding = ( + actionId: ShortcutActionId, + binding: KeyBinding | null, +): ClientKeyboardSettings => { + const current = getClientKeyboardSettings () + + return setClientKeyboardSettings ({ + ...current, + bindings: { ...(current.bindings ?? { }), [actionId]: binding } }) +} + + +export const resetClientKeyBinding = ( + actionId: ShortcutActionId, +): ClientKeyboardSettings => { + const current = getClientKeyboardSettings () + const nextBindings = { ...(current.bindings ?? { }) } + delete nextBindings[actionId] + + return setClientKeyboardSettings ({ ...current, bindings: nextBindings }) +} + + +export const resetAllClientKeyBindings = (): ClientKeyboardSettings => { + const current = getClientKeyboardSettings () + + return setClientKeyboardSettings ({ ...current, bindings: { } }) +} + + +const normaliseHexColour = (value: string): string | null => + /^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null + + +const isBuiltinThemeId = (value: string): value is BuiltinThemeId => + value === 'light' || value === 'dark' + + +const isCustomThemeId = (value: string): value is `custom:${ string }` => + value.startsWith ('custom:') + + +const themeCopyName = (baseTheme: BuiltinThemeId): string => + `${ BUILTIN_THEMES[baseTheme].name }のコピー` + + +const appearanceFromSettings = (): ClientAppearanceSettings => + loadClientSettings ().appearance ?? { } + + +let cachedUserThemeSlots: UserThemeSlotMap = { } + + +export const hasStoredClientThemeSelection = (): boolean => { + const appearance = appearanceFromSettings () + return (appearance.themeMode != null + || appearance.activeLightThemeSlotNo != null + || appearance.activeDarkThemeSlotNo != null + || appearance.activeThemeSlot != null + || appearance.activeThemeId != null + || appearance.customThemes != null + || appearance.theme != null + || appearance.tagColours != null) +} + + +const mixHexColour = ( + source: string, + target: string, + ratio: number, +): string => { + const clampedRatio = Math.max (0, Math.min (1, ratio)) + const src = source.slice (1) + const dst = target.slice (1) + const channels = [0, 2, 4].map (offset => { + const srcValue = parseInt (src.slice (offset, offset + 2), 16) + const dstValue = parseInt (dst.slice (offset, offset + 2), 16) + const mixed = Math.round (srcValue + (dstValue - srcValue) * clampedRatio) + return mixed.toString (16).padStart (2, '0') + }) + + return `#${ channels.join ('') }` +} + + +// Legacy appearance-model compatibility. +// The current settings UI uses `themeMode` plus the light/dark slot numbers. +// The helpers below remain only to read older localStorage shapes. + +const legacyThemeSlotSelection = (): ThemeSlotSelection => { + const appearance = appearanceFromSettings () + const customThemes = getClientCustomThemes () + + if (appearance.activeThemeId === 'system') + return 'system' + if (appearance.activeThemeId === 'light') + return 'builtin:light' + if (appearance.activeThemeId === 'dark') + return 'builtin:dark' + if (typeof appearance.activeThemeId === 'string' + && customThemes[appearance.activeThemeId] != null) + return `builtin:${ customThemes[appearance.activeThemeId].baseTheme }` + if (appearance.theme === 'light') + return 'builtin:light' + if (appearance.theme === 'dark') + return 'builtin:dark' + return 'system' +} + + +const legacyThemeMode = (): ClientThemeMode => + normaliseThemeMode (appearanceFromSettings ().theme) + ?? (legacyThemeSlotSelection () === 'system' + ? 'system' + : (baseThemeOfSelection (legacyThemeSlotSelection ()) ?? 'system')) + + +const legacyThemeSlotNoFor = ( + baseTheme: BuiltinThemeId, +): UserThemeSlotNo => { + const legacySelection = legacyThemeSlotSelection () + + if (isUserThemeSlotSelection (legacySelection)) + { + const [, selectionBaseTheme, slotNo] = legacySelection.split (':') + if (selectionBaseTheme === baseTheme) + return Number (slotNo) as UserThemeSlotNo + } + + return 1 +} + + +export const getClientActiveThemeSlot = (): ThemeSlotSelection => + normaliseThemeSlotSelection (appearanceFromSettings ().activeThemeSlot) + ?? legacyThemeSlotSelection () + + +// Legacy write path. New code should use `setClientThemeAppearance`. +export const setClientActiveThemeSlot = ( + activeThemeSlot: ThemeSlotSelection, +): void => { + updateClientSettings (settings => ({ + ...settings, + appearance: { ...(settings.appearance ?? { }), activeThemeSlot } })) +} + + +export const getClientThemeMode = (): UserSettings['theme'] => + normaliseThemeMode (appearanceFromSettings ().themeMode) ?? legacyThemeMode () + + +export const getClientActiveLightThemeSlotNo = (): UserThemeSlotNo => + normaliseThemeSlotNo (appearanceFromSettings ().activeLightThemeSlotNo) + ?? legacyThemeSlotNoFor ('light') + + +export const getClientActiveDarkThemeSlotNo = (): UserThemeSlotNo => + normaliseThemeSlotNo (appearanceFromSettings ().activeDarkThemeSlotNo) + ?? legacyThemeSlotNoFor ('dark') + + +export const setClientThemeAppearance = ( + { themeMode, + activeLightThemeSlotNo, + activeDarkThemeSlotNo }: { themeMode: ClientThemeMode + activeLightThemeSlotNo: UserThemeSlotNo + activeDarkThemeSlotNo: UserThemeSlotNo }, +): void => { + updateClientSettings (settings => ({ + ...settings, + appearance: { + ...(settings.appearance ?? { }), + themeMode, + activeLightThemeSlotNo, + activeDarkThemeSlotNo } })) +} + + +export const setClientThemeMode = (theme: UserSettings['theme']): void => { + setClientThemeAppearance ({ + themeMode: theme, + activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (), + activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo () }) +} + + +export const seedClientThemeMode = (theme: UserSettings['theme']): void => { + if (hasStoredClientThemeSelection ()) + return + + setClientThemeMode (theme) +} + + +const prefersDarkTheme = (): boolean => + window.matchMedia ('(prefers-color-scheme: dark)').matches + + +export const resolveThemeMode = ( + theme: UserSettings['theme'], +): ResolvedTheme => + theme === 'system' + ? (prefersDarkTheme () ? 'dark' : 'light') + : theme + + +export const defaultThemeTagColoursFor = ( + theme: ResolvedTheme, +): ThemeTagColours => + theme === 'dark' + ? DEFAULT_DARK_THEME_TAG_COLOURS + : DEFAULT_LIGHT_THEME_TAG_COLOURS + + +const normaliseThemeTagColours = ( + input: Partial | null | undefined, + fallback: ThemeTagColours, +): ThemeTagColours => + CATEGORIES.reduce ( + (colours, category) => { + const candidate = input?.[category] + colours[category] = + typeof candidate === 'string' && normaliseHexColour (candidate) != null + ? candidate + : fallback[category] + return colours + }, + { ...fallback }) + + +const normaliseThemeTokenValue = ( + value: unknown, + fallback: string, +): string => + typeof value === 'string' && value !== '' ? value : fallback + + +const cssColourFromThemeToken = ( + 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 clamp = (value: number, min: number, max: number): number => + Math.min (Math.max (value, min), max) + + +const parseHslComponents = ( + value: string, +): { h: number; s: number; l: number } | null => { + const raw = (value.startsWith ('hsl(') && value.endsWith (')') + ? value.slice (4, -1) + : value) + const match = + raw.match (/^(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/) + + if (match == null) + return null + + return { + h: Number (match[1]), + s: Number (match[2]), + l: Number (match[3]) } +} + + +const formatHslToken = ( + { h, s, l }: { h: number + s: number + l: number }, +): string => + `${ h.toFixed (1).replace (/\.0$/, '') }` + + ` ${ s.toFixed (1).replace (/\.0$/, '') }%` + + ` ${ l.toFixed (1).replace (/\.0$/, '') }%` + + +const hslLightness = (value: string): number | null => + parseHslComponents (value)?.l ?? null + + +const readableForegroundFor = (value: string): string => + (hslLightness (value) ?? 50) < 56 + ? LIGHT_THEME_TOKENS.primaryForeground + : DARK_THEME_TOKENS.primaryForeground + + +const deriveMutedForeground = ( + foreground: string, + background: string, + fallback: string, +): string => { + const foregroundHsl = parseHslComponents (foreground) + const backgroundHsl = parseHslComponents (background) + + if (foregroundHsl == null || backgroundHsl == null) + return fallback + + return formatHslToken ({ + h: foregroundHsl.h, + s: clamp (foregroundHsl.s * .6, 0, 100), + l: clamp ( + foregroundHsl.l + (backgroundHsl.l - foregroundHsl.l) * .35, + 0, + 100) }) +} + + +const deriveTopNavTokens = ( + baseTheme: BuiltinThemeId, + editable: EditableTopNavTokens, + link: string, +): ResolvedTopNavTokens => ({ + rootBackgroundMobile: editable.rootBackgroundMobile, + rootBackgroundDesktop: editable.rootBackgroundDesktop, + activeBackground: editable.activeBackground, + submenuBackground: editable.activeBackground, + mobileMenuBackground: editable.rootBackgroundMobile, + mobileActiveBackground: ( + baseTheme === 'dark' + ? editable.activeBackground + : editable.rootBackgroundDesktop), + brandLink: editable.brandLink, + menuLink: link }) + + +export const buildThemeTokens = ( + baseTheme: BuiltinThemeId, + rawTokens: unknown, +): ThemeTokens => { + const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS + const tokens = + rawTokens && typeof rawTokens === 'object' + ? rawTokens as Partial + : { } + const legacyTopNavTokens = + rawTokens && typeof rawTokens === 'object' + ? rawTokens as Record + : { } + + const background = + normaliseThemeTokenValue (tokens.background, fallback.background) + const foreground = + normaliseThemeTokenValue (tokens.foreground, fallback.foreground) + const card = + normaliseThemeTokenValue (tokens.card, fallback.card) + const muted = + normaliseThemeTokenValue (tokens.muted, fallback.muted) + const primary = + normaliseThemeTokenValue (tokens.primary, fallback.primary) + const accent = + normaliseThemeTokenValue (tokens.accent, fallback.accent) + const border = + normaliseThemeTokenValue (tokens.border, fallback.border) + const link = + normaliseThemeTokenValue (tokens.link, fallback.link) + const topNavRootBackgroundMobile = normaliseTopNavColourValue ( + tokens.topNavRootBackgroundMobile + ?? legacyTopNavTokens.topNavRootBackgroundMobile + ?? legacyTopNavTokens.topNavBackground, + fallback.topNavRootBackgroundMobile) + const topNavRootBackgroundDesktop = normaliseTopNavColourValue ( + tokens.topNavRootBackgroundDesktop + ?? legacyTopNavTokens.topNavRootBackgroundDesktop + ?? legacyTopNavTokens.topNavBackground, + fallback.topNavRootBackgroundDesktop) + const topNavActiveBackground = normaliseTopNavColourValue ( + tokens.topNavActiveBackground + ?? legacyTopNavTokens.topNavActiveBackground + ?? legacyTopNavTokens.topNavHighlightBackground + ?? legacyTopNavTokens.topNavHighlightBackground, + fallback.topNavActiveBackground) + const topNavBrandLink = normaliseTopNavColourValue ( + tokens.topNavBrandLink + ?? legacyTopNavTokens.topNavBrandLink + ?? legacyTopNavTokens.topNavLink, + fallback.topNavBrandLink) + const topNavMenuLink = cssColourFromThemeToken ( + link, + fallback.topNavMenuLink) + const resolvedTopNav = deriveTopNavTokens ( + baseTheme, + { rootBackgroundMobile: topNavRootBackgroundMobile, + rootBackgroundDesktop: topNavRootBackgroundDesktop, + activeBackground: topNavActiveBackground, + brandLink: topNavBrandLink }, + topNavMenuLink) + + return { + background, + foreground, + card, + cardForeground: foreground, + popover: card, + popoverForeground: foreground, + primary, + primaryForeground: readableForegroundFor (primary), + secondary: muted, + secondaryForeground: foreground, + muted, + mutedForeground: deriveMutedForeground ( + foreground, + background, + fallback.mutedForeground), + accent, + accentForeground: readableForegroundFor (accent), + destructive: fallback.destructive, + destructiveForeground: fallback.destructiveForeground, + border, + input: border, + ring: primary, + link, + topNavRootBackgroundMobile: resolvedTopNav.rootBackgroundMobile, + topNavRootBackgroundDesktop: resolvedTopNav.rootBackgroundDesktop, + topNavActiveBackground: resolvedTopNav.activeBackground, + topNavSubmenuBackground: resolvedTopNav.submenuBackground, + topNavMobileActiveBackground: resolvedTopNav.mobileActiveBackground, + topNavBrandLink: resolvedTopNav.brandLink, + topNavMenuLink: resolvedTopNav.menuLink, + tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours) } +} + + +const normaliseTopNavColourValue = ( + value: unknown, + fallback: string, +): string => cssColourFromThemeToken (value, fallback) + + +const normaliseThemeTokens = ( + rawTokens: unknown, + baseTheme: BuiltinThemeId, +): ThemeTokens => { + const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS + const tokens = + rawTokens && typeof rawTokens === 'object' + ? rawTokens as Partial + : { } + const legacyTopNavTokens = + rawTokens && typeof rawTokens === 'object' + ? rawTokens as Record + : { } + + const background = + normaliseThemeTokenValue (tokens.background, fallback.background) + const foreground = + normaliseThemeTokenValue (tokens.foreground, fallback.foreground) + const card = + normaliseThemeTokenValue (tokens.card, fallback.card) + const muted = + normaliseThemeTokenValue (tokens.muted, fallback.muted) + const primary = + normaliseThemeTokenValue (tokens.primary, fallback.primary) + const accent = + normaliseThemeTokenValue (tokens.accent, fallback.accent) + const border = + normaliseThemeTokenValue (tokens.border, fallback.border) + const link = + normaliseThemeTokenValue (tokens.link, fallback.link) + + return buildThemeTokens ( + baseTheme, + { ...tokens, + background, + foreground, + card, + muted, + primary, + accent, + border, + link, + topNavRootBackgroundMobile: + tokens.topNavRootBackgroundMobile + ?? legacyTopNavTokens.topNavRootBackgroundMobile + ?? legacyTopNavTokens.topNavBackground, + topNavRootBackgroundDesktop: + tokens.topNavRootBackgroundDesktop + ?? legacyTopNavTokens.topNavRootBackgroundDesktop + ?? legacyTopNavTokens.topNavBackground, + topNavActiveBackground: + tokens.topNavActiveBackground + ?? legacyTopNavTokens.topNavActiveBackground + ?? legacyTopNavTokens.topNavHighlightBackground, + topNavBrandLink: + tokens.topNavBrandLink + ?? legacyTopNavTokens.topNavBrandLink + ?? legacyTopNavTokens.topNavLink, + topNavMenuLink: + tokens.topNavMenuLink + ?? legacyTopNavTokens.topNavMenuLink + ?? link, + tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours) }) +} + + +export const getUserThemeSlotSelection = ( + baseTheme: BuiltinThemeId, + slotNo: UserThemeSlotNo, +): UserThemeSlotSelection => `user:${ baseTheme }:${ slotNo }` + + +const isBuiltinThemeSlotSelection = ( + value: string, +): value is `builtin:${ BuiltinThemeId }` => + value === 'builtin:light' || value === 'builtin:dark' + + +export const isUserThemeSlotSelection = ( + value: unknown, +): value is UserThemeSlotSelection => + typeof value === 'string' + && USER_THEME_SLOT_SELECTIONS.includes (value as UserThemeSlotSelection) + + +const normaliseThemeSlotSelection = ( + value: unknown, +): ThemeSlotSelection | null => { + if (value === 'system') + return 'system' + if (typeof value === 'string' && isBuiltinThemeSlotSelection (value)) + return value + if (isUserThemeSlotSelection (value)) + return value + return null +} + + +const baseThemeOfSelection = ( + selection: ThemeSlotSelection, +): BuiltinThemeId | null => { + if (selection === 'system') + return null + if (isBuiltinThemeSlotSelection (selection)) + return selection.slice ('builtin:'.length) as BuiltinThemeId + + return selection.split (':')[1] as BuiltinThemeId +} + + +const slotNoOfSelection = ( + selection: UserThemeSlotSelection, +): UserThemeSlotNo => Number (selection.split (':')[2]) as UserThemeSlotNo + + +export const getDefaultUserThemeSlot = ( + baseTheme: BuiltinThemeId, + slotNo: UserThemeSlotNo, +): UserThemeSlot => ({ + baseTheme, + slotNo, + tokens: normaliseThemeTokens ({ }, baseTheme) }) + + +const normaliseUserThemeSlot = ( + rawSlot: unknown, +): UserThemeSlot | null => { + if (!(rawSlot) || typeof rawSlot !== 'object') + return null + + const slot = rawSlot as Partial + const baseTheme = slot.baseTheme + const slotNo = slot.slotNo + + if (baseTheme == null + || !(isBuiltinThemeId (baseTheme)) + || slotNo == null + || !(USER_THEME_SLOT_NOS.includes (slotNo as UserThemeSlotNo))) + return null + + return { + baseTheme, + slotNo: slotNo as UserThemeSlotNo, + tokens: normaliseThemeTokens (slot.tokens, baseTheme), + createdAt: typeof slot.createdAt === 'string' ? slot.createdAt : undefined, + updatedAt: typeof slot.updatedAt === 'string' ? slot.updatedAt : undefined } +} + + +export const normaliseUserThemeSlots = ( + slots: UserThemeSlot[], +): UserThemeSlotMap => + slots.reduce ( + (map, slot) => { + map[getUserThemeSlotSelection (slot.baseTheme, slot.slotNo)] = slot + return map + }, + { }) + + +const normaliseCustomTheme = ( + rawTheme: unknown, +): CustomClientTheme | null => { + if (!(rawTheme) || typeof rawTheme !== 'object') + return null + + const theme = rawTheme as Record + const id = typeof theme.id === 'string' ? theme.id : null + const name = typeof theme.name === 'string' ? theme.name : null + const baseTheme = typeof theme.baseTheme === 'string' ? theme.baseTheme : null + if (id == null + || !(isCustomThemeId (id)) + || name == null + || baseTheme == null + || !(isBuiltinThemeId (baseTheme))) + return null + + return { + id, + name, + builtin: false, + baseTheme, + tokens: normaliseThemeTokens ( + { ...(theme.tokens && typeof theme.tokens === 'object' + ? theme.tokens as Partial + : { }), + tagColours: ( + (theme.tokens + && typeof theme.tokens === 'object' + && 'tagColours' in theme.tokens) + ? (theme.tokens as { tagColours?: Partial }).tagColours + : theme.tagColours as Partial | undefined) }, + baseTheme) } +} + + +export const getClientCustomThemes = (): Record => { + const appearance = appearanceFromSettings () + const customThemes = appearance.customThemes + + if (customThemes && typeof customThemes === 'object') + { + return Object.entries (customThemes).reduce> ( + (themes, [key, rawTheme]) => { + const theme = normaliseCustomTheme (rawTheme) + if (theme != null) + themes[key] = theme + return themes + }, + { }) + } + + const legacyTagColours = appearance.tagColours + if (!(legacyTagColours) || Object.keys (legacyTagColours).length === 0) + return { } + + const baseTheme = resolveThemeMode (appearance.theme ?? DEFAULT_USER_SETTINGS.theme) + return { + [LEGACY_MIGRATED_THEME_ID]: { + id: LEGACY_MIGRATED_THEME_ID, + name: themeCopyName (baseTheme), + builtin: false, + baseTheme, + tokens: { + ...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS), + tagColours: normaliseThemeTagColours ( + legacyTagColours, + defaultThemeTagColoursFor (baseTheme)) } } } +} + + +export const fetchUserThemeSlots = async (): Promise => { + const raw = await apiGet> ('/users/theme_slots') + + return (raw + .map (slot => normaliseUserThemeSlot (slot)) + .filter ((slot): slot is UserThemeSlot => slot != null)) +} + + +export const upsertUserThemeSlot = async ( + baseTheme: BuiltinThemeId, + slotNo: UserThemeSlotNo, + tokens: ThemeTokens, +): Promise => { + const raw = await apiPut<{ + baseTheme: BuiltinThemeId + slotNo: UserThemeSlotNo + tokens: ThemeTokens + createdAt: string + updatedAt: string }> (`/users/theme_slots/${ baseTheme }/${ slotNo }`, { tokens }) + const slot = normaliseUserThemeSlot (raw) + + if (slot == null) + throw new Error ('theme slot response is invalid') + + return slot +} + + +export const setCachedUserThemeSlots = (themeSlots: UserThemeSlotMap): void => { + cachedUserThemeSlots = themeSlots +} + + +export const getCachedUserThemeSlots = (): UserThemeSlotMap => + cachedUserThemeSlots + + +export const resolveDisplayedTheme = ( + { themeMode, + activeLightThemeSlotNo, + activeDarkThemeSlotNo }: { themeMode: ClientThemeMode + activeLightThemeSlotNo: UserThemeSlotNo + activeDarkThemeSlotNo: UserThemeSlotNo }, + themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), +): ResolvedDisplayedTheme => { + const baseTheme = resolveThemeMode (themeMode) + const slotNo = + baseTheme === 'dark' + ? activeDarkThemeSlotNo + : activeLightThemeSlotNo + const selection = getUserThemeSlotSelection (baseTheme, slotNo) + + return { + themeMode, + baseTheme, + selection, + slotNo, + tokens: (themeSlots[selection]?.tokens + ?? getDefaultUserThemeSlot (baseTheme, slotNo).tokens) } +} + + +export const normaliseThemeSlotSelectionAgainstSlots = ( + selection: ThemeSlotSelection, + themeSlots: UserThemeSlotMap, +): ThemeSlotSelection => + isUserThemeSlotSelection (selection) && themeSlots[selection] == null + ? 'system' + : selection + + +export const getClientActiveThemeId = (): ActiveThemeId => { + const appearance = appearanceFromSettings () + const customThemes = getClientCustomThemes () + + if (typeof appearance.activeThemeId === 'string' + && (appearance.activeThemeId === 'system' + || isBuiltinThemeId (appearance.activeThemeId) + || customThemes[appearance.activeThemeId] != null)) + return appearance.activeThemeId + + if (Object.keys (customThemes).length > 0 + && appearance.tagColours != null + && Object.keys (appearance.tagColours).length > 0) + return LEGACY_MIGRATED_THEME_ID + + if (typeof appearance.theme === 'string' + && (appearance.theme === 'system' + || appearance.theme === 'light' + || appearance.theme === 'dark')) + return appearance.theme + + return DEFAULT_USER_SETTINGS.theme +} + + +export const getThemeById = ( + themeId: ThemeId, + customThemes: Record, +): ClientTheme => + isBuiltinThemeId (themeId) + ? BUILTIN_THEMES[themeId] + : (customThemes[themeId] ?? BUILTIN_THEMES.light) + + +export const getResolvedThemeFromSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): ClientTheme => + activeThemeId === 'system' + ? BUILTIN_THEMES[resolveThemeMode ('system')] + : getThemeById (activeThemeId, customThemes) + + +export const deriveUserThemeModeFromSelection = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): UserSettings['theme'] => { + if (activeThemeId === 'system') + return 'system' + + return getThemeById (activeThemeId, customThemes).baseTheme +} + + +export const resolveThemeFromSlotSelection = ( + selection: ThemeSlotSelection, + themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), +): ResolvedThemeSelection => { + if (selection === 'system') + { + const baseTheme = resolveThemeMode ('system') + return { + selection, + baseTheme, + builtin: true, + tokens: BUILTIN_THEMES[baseTheme].tokens } + } + + if (isBuiltinThemeSlotSelection (selection)) + { + const baseTheme = selection.slice ('builtin:'.length) as BuiltinThemeId + return { + selection, + baseTheme, + builtin: true, + tokens: BUILTIN_THEMES[baseTheme].tokens } + } + + const baseTheme = baseThemeOfSelection (selection) ?? 'light' + return { + selection, + baseTheme, + builtin: false, + tokens: (themeSlots[selection]?.tokens + ?? getDefaultUserThemeSlot (baseTheme, slotNoOfSelection (selection)).tokens) } +} + + +export const deriveUserThemeModeFromSlotSelection = ( + selection: ThemeSlotSelection, +): UserSettings['theme'] => + selection === 'system' + ? 'system' + : (baseThemeOfSelection (selection) ?? DEFAULT_USER_SETTINGS.theme) + + +export const createCustomThemeFromBase = ( + baseTheme: BuiltinThemeId, + tokens?: ThemeTokens, +): CustomClientTheme => { + const id = createCustomThemeId () + return { + id, + name: themeCopyName (baseTheme), + builtin: false, + baseTheme, + tokens: tokens ?? { ...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS) } } +} + + +export const getClientThemeTagColours = ( + activeThemeId: ActiveThemeId = getClientActiveThemeId (), + customThemes: Record = getClientCustomThemes (), +): ThemeTagColours => + ({ ...getResolvedThemeFromSelection (activeThemeId, customThemes).tokens.tagColours }) + + +export const setClientAppearanceThemes = ( + activeThemeId: ActiveThemeId, + customThemes: Record, +): void => { + updateClientSettings (settings => ({ + ...settings, + appearance: { + ...(settings.appearance ?? { }), + activeThemeId, + customThemes, + theme: undefined, + tagColours: undefined } })) +} + + +export const setClientThemeTagColours = ( + tagColours: ThemeTagColours, + activeThemeId: ActiveThemeId = getClientActiveThemeId (), + customThemes: Record = getClientCustomThemes (), +): void => { + if (activeThemeId === 'system' || isBuiltinThemeId (activeThemeId)) + return + + setClientAppearanceThemes ( + activeThemeId, + { ...customThemes, + [activeThemeId]: { + ...customThemes[activeThemeId], + tokens: { ...customThemes[activeThemeId].tokens, tagColours } } }) +} + + +export const applyThemeMode = (theme: UserSettings['theme']): void => { + const root = document.documentElement + const resolvedTheme = resolveThemeMode (theme) + + root.classList.toggle ('dark', resolvedTheme === 'dark') + root.style.colorScheme = resolvedTheme +} + + +export const applyThemeTagColours = (tagColours: ThemeTagColours): void => { + const root = document.documentElement + + CATEGORIES.forEach (category => { + const colour = tagColours[category] + root.style.setProperty (`--tag-colour-${ category }`, colour) + root.style.setProperty ( + `--tag-colour-${ category }-hover`, + mixHexColour (colour, '#ffffff', .18)) + }) +} + + +export const applyThemeTokens = (tokens: ThemeTokens): void => { + const root = document.documentElement + + root.style.setProperty ('--background', tokens.background) + root.style.setProperty ('--foreground', tokens.foreground) + root.style.setProperty ('--card', tokens.card) + root.style.setProperty ('--card-foreground', tokens.cardForeground) + root.style.setProperty ('--popover', tokens.popover) + root.style.setProperty ('--popover-foreground', tokens.popoverForeground) + root.style.setProperty ('--primary', tokens.primary) + root.style.setProperty ('--primary-foreground', tokens.primaryForeground) + root.style.setProperty ('--secondary', tokens.secondary) + root.style.setProperty ('--secondary-foreground', tokens.secondaryForeground) + root.style.setProperty ('--muted', tokens.muted) + root.style.setProperty ('--muted-foreground', tokens.mutedForeground) + root.style.setProperty ('--accent', tokens.accent) + root.style.setProperty ('--accent-foreground', tokens.accentForeground) + root.style.setProperty ('--destructive', tokens.destructive) + root.style.setProperty ('--destructive-foreground', tokens.destructiveForeground) + root.style.setProperty ('--border', tokens.border) + root.style.setProperty ('--input', tokens.input) + root.style.setProperty ('--ring', tokens.ring) + root.style.setProperty ('--theme-link', tokens.link) + root.style.setProperty ('--top-nav-root-bg-mobile', tokens.topNavRootBackgroundMobile) + root.style.setProperty ('--top-nav-root-bg-desktop', tokens.topNavRootBackgroundDesktop) + root.style.setProperty ('--top-nav-active-bg', tokens.topNavActiveBackground) + root.style.setProperty ('--top-nav-submenu-bg', tokens.topNavSubmenuBackground) + root.style.setProperty ('--top-nav-mobile-menu-bg', tokens.topNavRootBackgroundMobile) + root.style.setProperty ('--top-nav-mobile-active-bg', tokens.topNavMobileActiveBackground) + root.style.setProperty ('--top-nav-brand-link', tokens.topNavBrandLink) + root.style.setProperty ('--top-nav-menu-link', tokens.topNavMenuLink) + + applyThemeTagColours (tokens.tagColours) +} + + +export const applyThemeSelection = ( + activeThemeSlot: ThemeSlotSelection, + themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), +): void => { + const theme = resolveThemeFromSlotSelection (activeThemeSlot, themeSlots) + applyThemeMode (theme.baseTheme) + applyThemeTokens (theme.tokens) +} + + +export const applyThemeAppearanceState = ( + { themeMode, + activeLightThemeSlotNo, + activeDarkThemeSlotNo }: { themeMode: ClientThemeMode + activeLightThemeSlotNo: UserThemeSlotNo + activeDarkThemeSlotNo: UserThemeSlotNo }, + themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), +): void => { + const theme = resolveDisplayedTheme ({ themeMode, + activeLightThemeSlotNo, + activeDarkThemeSlotNo }, themeSlots) + + applyThemeMode (theme.baseTheme) + applyThemeTokens (theme.tokens) +} + + +export const applyClientAppearance = ( + themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), +): void => { + applyThemeAppearanceState ({ + themeMode: getClientThemeMode (), + activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (), + activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo () }, themeSlots) +} + + +export const getClientListLimit = ( + listKey: ClientListKey, +): ClientListLimit | null => { + const value = loadClientSettings ().lists?.[listKey]?.limit + return validListLimit (value) +} + + +export const getClientListOrder = ( + listKey: ClientListKey, +): T | null => { + const value = loadClientSettings ().lists?.[listKey]?.order + return typeof value === 'string' ? value as T : null +} + + +export const setClientListSettings = ( + listKey: ClientListKey, + { limit, order }: { limit?: ClientListLimit + order?: string }, +): void => { + updateClientSettings (settings => ({ + ...settings, + lists: { + ...(settings.lists ?? { }), + [listKey]: { + ...(settings.lists?.[listKey] ?? { }), + ...(limit == null ? { } : { limit }), + ...(order == null ? { } : { order }) } } })) +} + + +const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => { + const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY) + return ((value === 'threeColumns' + || value === 'tagsBottom' + || value === 'commentsBottom') + ? value + : null) +} + + +const legacyTheatreTagFlow = (): TheatreTagFlow | null => { + const value = localStorage.getItem (LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY) + return (value === 'vertical' || value === 'horizontal') ? value : null +} + + +const legacyGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode | null => { + const value = localStorage.getItem (LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY) + return (value === 'on' || value === 'calm' || value === 'off') ? value : null +} + + +export const getClientTheatreLayoutMode = (): TheatreLayoutMode => + loadClientSettings ().theatre?.layoutMode + ?? legacyTheatreLayoutMode () + ?? 'threeColumns' + + +export const setClientTheatreLayoutMode = (layoutMode: TheatreLayoutMode): void => { + updateClientSettings (settings => ({ + ...settings, + theatre: { ...(settings.theatre ?? { }), layoutMode } })) +} + + +export const getClientTheatreTagFlow = (): TheatreTagFlow => + loadClientSettings ().theatre?.tagFlow + ?? legacyTheatreTagFlow () + ?? 'vertical' + + +export const setClientTheatreTagFlow = (tagFlow: TheatreTagFlow): void => { + updateClientSettings (settings => ({ + ...settings, + theatre: { ...(settings.theatre ?? { }), tagFlow } })) +} + + +export const getClientGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode => + loadClientSettings ().gekanator?.backgroundMotion + ?? legacyGekanatorBackgroundMotion () + ?? 'on' + + +export const setClientGekanatorBackgroundMotion = ( + backgroundMotion: GekanatorBackgroundMotionMode, +): void => { + updateClientSettings (settings => ({ + ...settings, + gekanator: { ...(settings.gekanator ?? { }), backgroundMotion } })) +} + + +export const getClientPaneWidthPx = ( + paneKey: string, + breakpoint: ClientPaneBreakpoint, +): number | null => { + const value = loadClientSettings ().panes?.[paneKey]?.widthPxByBreakpoint?.[breakpoint] + return typeof value === 'number' ? value : null +} + + +export const setClientPaneWidthPx = ( + paneKey: string, + breakpoint: ClientPaneBreakpoint, + widthPx: number, +): void => { + updateClientSettings (settings => ({ + ...settings, + panes: { + ...(settings.panes ?? { }), + [paneKey]: { + ...(settings.panes?.[paneKey] ?? { }), + widthPxByBreakpoint: { + ...(settings.panes?.[paneKey]?.widthPxByBreakpoint ?? { }), + [breakpoint]: widthPx } } } })) +} + + +export const setClientPaneCollapsed = ( + paneKey: string, + collapsed: boolean, +): void => { + updateClientSettings (settings => ({ + ...settings, + panes: { + ...(settings.panes ?? { }), + [paneKey]: { + ...(settings.panes?.[paneKey] ?? { }), + collapsed } } })) +} diff --git a/frontend/src/lib/useClientBehaviourSettings.ts b/frontend/src/lib/useClientBehaviourSettings.ts new file mode 100644 index 0000000..d587bd3 --- /dev/null +++ b/frontend/src/lib/useClientBehaviourSettings.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' + +import { + CLIENT_SETTINGS_EVENT, + getClientBehaviorSettings, +} from '@/lib/settings' + +import type { ClientBehaviorSettings } from '@/lib/settings' + + +export const useClientBehaviourSettings = (): ClientBehaviorSettings => { + const [settings, setSettings] = + useState (() => getClientBehaviorSettings ()) + + useEffect (() => { + const handleClientSettingsChange = () => { + setSettings (getClientBehaviorSettings ()) + } + + window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange) + return () => + window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange) + }, []) + + return settings +} diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx new file mode 100644 index 0000000..cc650bd --- /dev/null +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -0,0 +1,322 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog' +import { + DEFAULT_KEY_BINDINGS, + getConflictingShortcutActionIds, + isEditableEventTarget, + keyBindingFromKeyboardEvent, + resolveShortcutScope, + serialiseKeyBinding, + SHORTCUT_DEFINITIONS, + SHORTCUT_SCOPE_LABELS, +} from '@/lib/keyboardShortcuts' +import { + getClientKeyboardSettings, + getEffectiveKeyBindings, + setClientKeyboardSettings, +} from '@/lib/settings' +import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard' + +import type { + KeyBinding, + ShortcutActionId, + ShortcutScope, +} from '@/lib/keyboardShortcuts' +import type { ClientKeyboardSettings } from '@/lib/settings' +import type { PropsWithChildren } from 'react' + +type ShortcutHandler = () => void +type ShortcutHandlers = Partial> + +type KeyboardShortcutsContextValue = { + activeScope: ShortcutScope + keyboardSettings: ClientKeyboardSettings + effectiveBindings: Record + conflictActionIds: Set + saveKeyboardSettings: (settings: ClientKeyboardSettings) => void + registerHandlers: (handlers: ShortcutHandlers) => () => void +} + +const KeyboardShortcutsContext = + createContext (null) + + +const isFocusableShortcutTarget = (element: HTMLElement): boolean => { + if (element.closest ('[hidden], [aria-hidden="true"]') != null) + return false + + if ( + (element instanceof HTMLInputElement + || element instanceof HTMLButtonElement + || element instanceof HTMLSelectElement + || element instanceof HTMLTextAreaElement) + && element.disabled + ) + return false + + const style = window.getComputedStyle (element) + if ( + style.display === 'none' + || style.visibility === 'hidden' + || style.pointerEvents === 'none' + ) + return false + + return element.getClientRects ().length > 0 +} + + +const focusSearchTarget = (): void => { + const targets = [ + ...document.querySelectorAll ( + '[data-shortcut-focus-search="true"]', + ), + ] + const target = targets.find (isFocusableShortcutTarget) + + if (target == null) + return + + target.focus () + + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) + target.select () +} + + +export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { + const location = useLocation () + const navigate = useNavigate () + const { confirmDiscardNavigation } = useUnsavedChangesGuard () + + const [keyboardSettings, setKeyboardSettingsState] = + useState (() => getClientKeyboardSettings ()) + const [registeredHandlers, setRegisteredHandlers] = + useState> ({ }) + const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false) + const registrationIdRef = useRef (0) + + const activeScope = useMemo ( + () => resolveShortcutScope (location.pathname), + [location.pathname], + ) + const effectiveBindings = useMemo ( + () => getEffectiveKeyBindings (keyboardSettings.bindings), + [keyboardSettings.bindings], + ) + const conflictActionIds = useMemo ( + () => getConflictingShortcutActionIds (effectiveBindings), + [effectiveBindings], + ) + const mergedRegisteredHandlers = useMemo ( + () => Object.values (registeredHandlers).reduce ( + (handlers, currentHandlers) => ({ ...handlers, ...currentHandlers }), + { }, + ), + [registeredHandlers], + ) + + const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => { + const next = setClientKeyboardSettings (settings) + setKeyboardSettingsState (next) + }, []) + + const registerHandlers = useCallback ((handlers: ShortcutHandlers) => { + const registrationId = ++registrationIdRef.current + + setRegisteredHandlers (current => ({ + ...current, + [registrationId]: handlers, + })) + + return () => { + setRegisteredHandlers (current => { + const next = { ...current } + delete next[registrationId] + return next + }) + } + }, []) + + const guardedNavigate = useCallback ((path: string) => { + confirmDiscardNavigation ().then (confirmed => { + if (confirmed) + navigate (path) + }) + }, [confirmDiscardNavigation, navigate]) + + const builtinHandlers = useMemo ( + () => ({ + 'global.openShortcutHelp': () => { + setShortcutHelpOpen (true) + }, + 'global.focusSearch': () => { + focusSearchTarget () + }, + 'navigation.posts': () => guardedNavigate ('/posts'), + 'navigation.tags': () => guardedNavigate ('/tags'), + 'navigation.materials': () => guardedNavigate ('/materials'), + 'navigation.wiki': () => guardedNavigate ('/wiki'), + 'settings.account': () => guardedNavigate ('/users/settings?tab=account'), + 'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'), + 'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'), + 'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'), + }), + [guardedNavigate], + ) + + const availableActionIds = useMemo> ( + () => new Set ( + SHORTCUT_DEFINITIONS + .filter (definition => + definition.scope === 'global' || definition.scope === activeScope) + .filter (definition => + builtinHandlers[definition.id] != null + || mergedRegisteredHandlers[definition.id] != null) + .map (definition => definition.id), + ), + [activeScope, builtinHandlers, mergedRegisteredHandlers], + ) + + useEffect (() => { + const onKeyDown = (event: KeyboardEvent) => { + if ( + keyboardSettings.enabled === false + || event.defaultPrevented + || event.isComposing + || event.key === 'Process' + || event.repeat + ) + return + + if (isEditableEventTarget (event.target)) + return + + if (shortcutHelpOpen) + return + + const binding = keyBindingFromKeyboardEvent (event) + if (binding == null) + return + + const serialisedBinding = serialiseKeyBinding (binding) + if (serialisedBinding == null) + return + + const candidates = SHORTCUT_DEFINITIONS.filter (definition => ( + definition.scope === 'global' + || definition.scope === activeScope + )).filter (definition => { + const currentBinding = effectiveBindings[definition.id] + return ( + currentBinding != null + && serialiseKeyBinding (currentBinding) === serialisedBinding + ) + }).filter (definition => !(conflictActionIds.has (definition.id))) + + if (candidates.length !== 1) + return + + const actionId = candidates[0].id + const handler = mergedRegisteredHandlers[actionId] ?? builtinHandlers[actionId] + + if (handler == null) + return + + event.preventDefault () + handler () + } + + document.addEventListener ('keydown', onKeyDown) + return () => document.removeEventListener ('keydown', onKeyDown) + }, [ + activeScope, + conflictActionIds, + effectiveBindings, + builtinHandlers, + keyboardSettings.enabled, + mergedRegisteredHandlers, + shortcutHelpOpen, + ]) + + const value = useMemo (() => ({ + activeScope, + keyboardSettings, + effectiveBindings, + conflictActionIds, + saveKeyboardSettings, + registerHandlers, + }), [ + activeScope, + conflictActionIds, + effectiveBindings, + keyboardSettings, + registerHandlers, + saveKeyboardSettings, + ]) + + return ( + + {children} + + ) +} + + +export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => { + const context = useContext (KeyboardShortcutsContext) + + if (context == null) + throw new Error ('KeyboardShortcutsProvider が必要です.') + + return context +} + + +export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => { + const { registerHandlers } = useKeyboardShortcutSettings () + const handlersRef = useRef (handlers) + const actionIdsKey = Object.keys (handlers).sort ().join ('|') + + useEffect (() => { + handlersRef.current = handlers + }, [handlers]) + + const stableHandlers = useMemo (() => { + return actionIdsKey.split ('|').filter (actionId => actionId !== '') + .reduce ((nextHandlers, actionId) => { + const key = actionId as ShortcutActionId + nextHandlers[key] = () => { + handlersRef.current[key]?.() + } + return nextHandlers + }, { }) + }, [actionIdsKey]) + + useEffect ( + () => registerHandlers (stableHandlers), + [registerHandlers, stableHandlers], + ) +} + +export { + DEFAULT_KEY_BINDINGS, + SHORTCUT_DEFINITIONS, + SHORTCUT_SCOPE_LABELS, +} diff --git a/frontend/src/lib/useUnsavedChangesGuard.tsx b/frontend/src/lib/useUnsavedChangesGuard.tsx new file mode 100644 index 0000000..c007812 --- /dev/null +++ b/frontend/src/lib/useUnsavedChangesGuard.tsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useMemo, useState } from 'react' + +import { useDialogue } from '@/components/dialogues/DialogueProvider' + +import type { FC, PropsWithChildren } from 'react' + +type UnsavedChangesSource = { + dirty: boolean + discard: () => void | Promise } + +type UnsavedChangesGuardContextValue = { + hasUnsavedChanges: boolean + registerUnsavedChangesSource: ( + source: UnsavedChangesSource | null, + ) => void + confirmDiscardNavigation: () => Promise } + +const UnsavedChangesGuardContext = + createContext (null) + + +export const UnsavedChangesGuardProvider: FC = ({ children }) => { + const dialogue = useDialogue () + const [source, setSource] = useState (null) + + const registerUnsavedChangesSource = useCallback (( + nextSource: UnsavedChangesSource | null, + ) => { + setSource (nextSource) + }, []) + + const confirmDiscardNavigation = useCallback (async (): Promise => { + if (!(source?.dirty)) + return true + + const confirmed = await dialogue.confirm ({ + title: '未保存の変更があります', + description: 'このまま移動すると、保存していない変更は失われます。', + cancelText: 'このページに残る', + confirmText: '変更を破棄して移動', + variant: 'danger' }) + if (!(confirmed)) + return false + + await source.discard () + return true + }, [dialogue, source]) + + const value = useMemo (() => ({ + hasUnsavedChanges: source?.dirty === true, + registerUnsavedChangesSource, + confirmDiscardNavigation, + }), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty]) + + return ( + + {children} + ) +} + + +export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => { + const context = useContext (UnsavedChangesGuardContext) + + if (context == null) + throw new Error ('UnsavedChangesGuardProvider が必要です.') + + return context +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 64630d2..e50c55f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { HelmetProvider } from 'react-helmet-async' import '@/index.css' import App from '@/App' +import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings' const helmetContext = { } @@ -13,6 +14,8 @@ const client = new QueryClient ({ gcTime: 30 * 60 * 1000, retry: 1 } } }) +applyClientAnimationMode (getClientAnimationMode ()) + createRoot (document.getElementById ('root')!).render ( diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index 6ea1f84..181bb0c 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -24,6 +24,11 @@ import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery' import { isQuestionHardFilteredAfterAnswers, monthForCondition } from '@/lib/gekanatorQuestionFilters' import { gekanatorKeys } from '@/lib/queryKeys' +import { + getClientGekanatorBackgroundMotion, + setClientGekanatorBackgroundMotion, +} from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { FC } from 'react' @@ -185,7 +190,6 @@ const softenedAnswerWeight = .35 const confidenceTemperature = 6 const gameStorageKey = 'gekanator:game:v1' const recentGamesStorageKey = 'gekanator:recent-games:v1' -const backgroundMotionStorageKey = 'gekanator:background-motion:v1' const maxStoredRecentGames = 12 const specialOriginalMonthDayLabels: Record = { '1-1': '元日', @@ -429,18 +433,13 @@ const storeRecentGameSummary = ( const loadBackgroundMotionMode = (): BackgroundMotionMode => { - const fallbackMode = 'on' try { - const raw = localStorage.getItem (backgroundMotionStorageKey) - if (raw === 'off' || raw === 'calm' || raw === 'on') - return raw - - return fallbackMode + return getClientGekanatorBackgroundMotion () } catch { - return fallbackMode + return 'on' } } @@ -3679,6 +3678,8 @@ const expectedAnswerFor = ( const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const storedGame = useMemo (loadStoredGame, []) const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase) const queryClient = useQueryClient () @@ -3916,7 +3917,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { useEffect (() => { try { - localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode) + setClientGekanatorBackgroundMotion (backgroundMotionMode) } catch { @@ -4071,9 +4072,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { return reviewGuessedPostId === reviewCorrectPostId }) () const effectiveBackgroundMotionMode = (() => { + if (animationMode === 'off') + return 'off' + if (backgroundMotionMode === 'off') return 'off' - if (prefersReducedMotion) + if (animationMode === 'reduced' || prefersReducedMotion) return 'calm' return backgroundMotionMode diff --git a/frontend/src/pages/materials/MaterialHistoryPage.tsx b/frontend/src/pages/materials/MaterialHistoryPage.tsx index 82876db..920fbad 100644 --- a/frontend/src/pages/materials/MaterialHistoryPage.tsx +++ b/frontend/src/pages/materials/MaterialHistoryPage.tsx @@ -9,14 +9,18 @@ import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' +import { clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchMaterialChanges } from '@/lib/materials' import { materialsKeys } from '@/lib/queryKeys' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { dateString, inputClass } from '@/lib/utils' import type { FC, FormEvent } from 'react' const MaterialHistoryPage: FC = () => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const location = useLocation () const navigate = useNavigate () const query = new URLSearchParams (location.search) @@ -55,8 +59,9 @@ const MaterialHistoryPage: FC = () => { const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search]) const handleSearch = (event: FormEvent) => { event.preventDefault () diff --git a/frontend/src/pages/materials/MaterialNewPage.tsx b/frontend/src/pages/materials/MaterialNewPage.tsx index 40f233f..ef8c6a9 100644 --- a/frontend/src/pages/materials/MaterialNewPage.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.tsx @@ -79,7 +79,7 @@ const MaterialNewPage: FC = () => { {/* タグ */} - + {({ describedBy, invalid }) => ( = ({ user }) => { const editable = canEditContent (user) + const behaviourSettings = useClientBehaviourSettings () + const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal' + const animationMode = behaviourSettings.animation ?? 'normal' const { id } = useParams () + const heroLayoutId = animationMode === 'off' ? undefined : `page-${ id }` + const heroTransition = + animationMode === 'off' + ? { duration: 0 } + : animationMode === 'reduced' + ? { duration: .08, ease: 'linear' as const } + : { duration: .2, ease: 'easeOut' as const } const postId = String (id ?? '') const postKey = postsKeys.show (postId) @@ -134,17 +145,19 @@ const PostDetailPage: FC = ({ user }) => {
) })} - {(post.thumbnail || post.thumbnailBase) && ( + {thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && ( + transition={heroTransition}> {post.title )} diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index f6f961d..71aa3c8 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -12,9 +12,12 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider' import MainArea from '@/components/layout/MainArea' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' +import { clientAnimationTransition, + clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchPostChanges, updatePost } from '@/lib/posts' import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { fetchTag } from '@/lib/tags' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn, dateString, originalCreatedAtString } from '@/lib/utils' import type { FC, MouseEvent } from 'react' @@ -37,6 +40,12 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => ( const PostHistoryPage: FC = () => { const dialogue = useDialogue () + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const location = useLocation () const query = new URLSearchParams (location.search) @@ -113,8 +122,9 @@ const PostHistoryPage: FC = () => { } useEffect (() => { - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search]) const layoutIds: string[] = [] @@ -198,8 +208,8 @@ const PostHistoryPage: FC = () => { rowSpan={rowsCnt}> + layoutId={animationMode === 'off' ? undefined : layoutId} + transition={{ layout: layoutTransition }}> ({ fetchPosts: vi.fn (), @@ -18,6 +19,11 @@ vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/wiki', () => wikiApi) describe ('PostListPage', () => { + beforeEach (() => { + localStorage.clear () + vi.clearAllMocks () + }) + it ('loads posts from the current query and renders the plaza tab', async () => { const tag = buildTag ({ name: '虹夏' }) postsApi.fetchPosts.mockResolvedValueOnce ({ @@ -36,6 +42,7 @@ describe ('PostListPage', () => { match: 'all', page: 2, limit: 20, + order: 'original_created_at:desc', }), ) }) @@ -71,4 +78,36 @@ describe ('PostListPage', () => { '/wiki/%E8%99%B9%E5%A4%8F', ) }) + + it ('uses stored list limit but keeps the fixed plaza order', async () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { postList: { limit: 50, order: 'title:asc' } } })) + postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) + + renderWithProviders (, { route: '/posts' }) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenCalledWith ( + expect.objectContaining ({ + limit: 50, + order: 'original_created_at:desc', + }), + ) + }) + }) + + it ('updates the limit from the tab right addon and resets to page 1', async () => { + postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) + + renderWithProviders (, { route: '/posts?page=3' }) + + fireEvent.change (await screen.findByLabelText ('件数'), { target: { value: '100' } }) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( + expect.objectContaining ({ page: 1, limit: 100 }), + ) + }) + }) }) diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index 6b116c8..6f56c9d 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' -import { useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import PostList from '@/components/PostList' import PrefetchLink from '@/components/PrefetchLink' @@ -13,12 +13,24 @@ import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { fetchPosts } from '@/lib/posts' import { postsKeys } from '@/lib/queryKeys' +import { + DEFAULT_POST_LIST_LIMIT, + getClientListLimit, + LIST_LIMIT_OPTIONS, + setClientListSettings, +} from '@/lib/settings' +import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { fetchWikiPageByTitle } from '@/lib/wiki' import type { FC } from 'react' import type { WikiPage } from '@/types' +const parseLimit = (value: string | null): 20 | 50 | 100 | null => { + const n = Number (value) + return n === 20 || n === 50 || n === 100 ? n : null +} + const PostListPage: FC = () => { const containerRef = useRef (null) @@ -26,6 +38,7 @@ const PostListPage: FC = () => { const [wikiPage, setWikiPage] = useState (null) const location = useLocation () + const navigate = useNavigate () const query = new URLSearchParams (location.search) const tagsQuery = query.get ('tags') ?? '' const anyFlg = query.get ('match') === 'any' @@ -33,13 +46,18 @@ const PostListPage: FC = () => { const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery]) const tagsKey = tags.join (' ') const page = Number (query.get ('page') ?? 1) - const limit = Number (query.get ('limit') ?? 20) + const limit = ( + parseLimit (query.get ('limit')) + ?? getClientListLimit ('postList') + ?? DEFAULT_POST_LIST_LIMIT + ) + const order = 'original_created_at:desc' as const const keys = { tags: tagsKey, match, page, limit, url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '', createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', - order: 'original_created_at:desc' } as const + order } as const const { data, isLoading: loading } = useQuery ({ queryKey: postsKeys.index (keys), queryFn: () => fetchPosts (keys) }) @@ -47,6 +65,40 @@ const PostListPage: FC = () => { const cursor = '' const totalPages = data ? Math.ceil (data.count / limit) : 0 + useEffect (() => { + setClientListSettings ('postList', { limit }) + }, [limit]) + + const updatePage = (nextPage: number) => { + const params = new URLSearchParams (location.search) + params.set ('page', String (Math.max (1, nextPage))) + navigate (`${ location.pathname }?${ params.toString () }`) + } + + const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => { + const params = new URLSearchParams (location.search) + params.set ('limit', String (next.limit ?? limit)) + params.set ('page', '1') + navigate (`${ location.pathname }?${ params.toString () }`) + } + + useKeyboardShortcuts (useMemo (() => ({ + ...(page > 1 + ? { + 'pagination.previous': () => { + updatePage (page - 1) + }, + } + : { }), + ...(totalPages > 0 && page < totalPages + ? { + 'pagination.next': () => { + updatePage (Math.min (page + 1, totalPages)) + }, + } + : { }), + }), [page, totalPages, location.search])) + useLayoutEffect (() => { scroll (0, 0) @@ -90,7 +142,24 @@ const PostListPage: FC = () => { }}/> - + + 件数 + + + }> {posts.length > 0 ? ( diff --git a/frontend/src/pages/posts/PostNewPage.test.tsx b/frontend/src/pages/posts/PostNewPage.test.tsx index 1402ed0..066e796 100644 --- a/frontend/src/pages/posts/PostNewPage.test.tsx +++ b/frontend/src/pages/posts/PostNewPage.test.tsx @@ -30,16 +30,12 @@ describe ('PostNewPage', () => { expect (screen.getByText ('403')).toBeInTheDocument () }) - it ('submits a new post with manual title and thumbnail settings', async () => { + it ('submits a new post with manual title and thumbnail fetch UI', async () => { api.apiPost.mockResolvedValueOnce ({}) api.apiGet.mockResolvedValue ([]) renderWithProviders () - const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' }) - fireEvent.click (checkboxes[0]) - fireEvent.click (checkboxes[1]) - const textboxes = screen.getAllByRole ('textbox') fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) @@ -97,10 +93,6 @@ describe ('PostNewPage', () => { renderWithProviders () - const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' }) - fireEvent.click (checkboxes[0]) - fireEvent.click (checkboxes[1]) - const textboxes = screen.getAllByRole ('textbox') fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) diff --git a/frontend/src/pages/posts/PostNewPage.tsx b/frontend/src/pages/posts/PostNewPage.tsx index 0a99dc5..14c1b50 100644 --- a/frontend/src/pages/posts/PostNewPage.tsx +++ b/frontend/src/pages/posts/PostNewPage.tsx @@ -41,16 +41,13 @@ const PostNewPage: FC = ({ user }) => { const [parentPostIds, setParentPostIds] = useState ('') const [tags, setTags] = useState ('') const [duration, setDuration] = useState ('') - const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) const [thumbnailFile, setThumbnailFile] = useState (null) const [thumbnailLoading, setThumbnailLoading] = useState (false) const [thumbnailPreview, setThumbnailPreview] = useState ('') const [title, setTitle] = useState ('') - const [titleAutoFlg, setTitleAutoFlg] = useState (true) const [titleLoading, setTitleLoading] = useState (false) const [url, setURL] = useState ('') - const previousURLRef = useRef ('') const thumbnailPreviewRef = useRef ('') const videoFlg = useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'), @@ -86,23 +83,17 @@ const PostNewPage: FC = ({ user }) => { } } - const handleURLBlur = () => { - if (!(url) || url === previousURLRef.current) - return - - if (titleAutoFlg) - fetchTitle () - if (thumbnailAutoFlg) - fetchThumbnail () - previousURLRef.current = url - } - const fetchTitle = useCallback (async () => { - setTitle ('') setTitleLoading (true) - const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) - setTitle (data.title || '') - setTitleLoading (false) + try + { + const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) + setTitle (data.title || '') + } + finally + { + setTitleLoading (false) + } }, [url]) const fetchThumbnail = useCallback (async () => { @@ -111,30 +102,26 @@ const PostNewPage: FC = ({ user }) => { setThumbnailLoading (true) if (thumbnailPreviewRef.current) URL.revokeObjectURL (thumbnailPreviewRef.current) - const data = await apiGet ('/preview/thumbnail', - { params: { url }, responseType: 'blob' }) - const imageURL = URL.createObjectURL (data) - setThumbnailPreview (imageURL) - setThumbnailFile (new File ([data], - 'thumbnail.png', - { type: data.type || 'image/png' })) - setThumbnailLoading (false) + try + { + const data = await apiGet ('/preview/thumbnail', + { params: { url }, responseType: 'blob' }) + const imageURL = URL.createObjectURL (data) + setThumbnailPreview (imageURL) + setThumbnailFile (new File ([data], + 'thumbnail.png', + { type: data.type || 'image/png' })) + } + finally + { + setThumbnailLoading (false) + } }, [url]) useEffect (() => { thumbnailPreviewRef.current = thumbnailPreview }, [thumbnailPreview]) - useEffect (() => { - if (titleAutoFlg && url) - fetchTitle () - }, [fetchTitle, titleAutoFlg, url]) - - useEffect (() => { - if (thumbnailAutoFlg && url) - fetchThumbnail () - }, [fetchThumbnail, thumbnailAutoFlg, url]) - if (!(editable)) return @@ -156,59 +143,62 @@ const PostNewPage: FC = ({ user }) => { onChange={e => setURL (e.target.value)} aria-describedby={describedBy} aria-invalid={invalid} - className={inputClass (invalid)} - onBlur={handleURLBlur}/>)} + className={inputClass (invalid)}/>)} {/* タイトル */} - setTitleAutoFlg (ev.target.checked)}} - label="タイトル" - messages={fieldErrors.title}> + {({ describedBy, invalid }) => ( - setTitle (ev.target.value)} - disabled={titleAutoFlg}/>)} +
+ setTitle (ev.target.value)} + disabled={titleLoading}/> +
+ 必要なタイミングで URL から取得できます. + +
+
)}
{/* サムネール */} - setThumbnailAutoFlg (ev.target.checked)}} - label="サムネール" - messages={fieldErrors.thumbnail}> + {({ describedBy, invalid }) => ( <> - {thumbnailAutoFlg - ? (thumbnailLoading - ?

Loading...

- : !(thumbnailPreview) && ( -

- URL から自動取得されます。 -

)) - : ( - { - const file = e.target.files?.[0] - if (file) - { - setThumbnailFile (file) - setThumbnailPreview (URL.createObjectURL (file)) - } - }}/>)} +
+ 必要なタイミングで URL から取得できます. + +
+ {thumbnailLoading && ( +

Loading...

)} + { + const file = e.target.files?.[0] + if (file) + { + setThumbnailFile (file) + setThumbnailPreview (URL.createObjectURL (file)) + } + }}/> {thumbnailPreview && ( preview ({ fetchPosts: vi.fn (), @@ -18,6 +19,7 @@ vi.mock ('@/lib/api', () => api) describe ('PostSearchPage', () => { beforeEach (() => { + localStorage.clear () vi.clearAllMocks () api.apiGet.mockResolvedValue ([]) }) @@ -40,6 +42,7 @@ describe ('PostSearchPage', () => { tags: 'x', match: 'any', page: 2, + limit: 20, }), ) }) @@ -81,4 +84,41 @@ describe ('PostSearchPage', () => { expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () }) + + it ('uses URL limit before localStorage and stores the active list settings', async () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { postSearch: { limit: 100, order: 'title:asc' } } })) + postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) + + renderWithProviders ( + , + { route: '/posts/search?limit=50&order=created_at%3Adesc' }, + ) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenCalledWith ( + expect.objectContaining ({ limit: 50, order: 'created_at:desc' }), + ) + }) + expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) + .toMatchObject ({ lists: { postSearch: { limit: 50, order: 'created_at:desc' } } }) + }) + + it ('moves the compact limit control outside the search form and resets page on change', async () => { + postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) + + renderWithProviders (, { route: '/posts/search?page=4' }) + + const limitSelect = await screen.findByLabelText ('件数') + expect (limitSelect.closest ('form')).toBeNull () + + fireEvent.change (limitSelect, { target: { value: '100' } }) + + await waitFor (() => { + expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( + expect.objectContaining ({ page: 1, limit: 100 }), + ) + }) + }) }) diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 7237c9a..47b29ec 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -14,8 +14,20 @@ import Pagination from '@/components/common/Pagination' import TagInput from '@/components/common/TagInput' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' +import { clientAnimationTransition, + clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchPosts } from '@/lib/posts' import { postsKeys } from '@/lib/queryKeys' +import { + DEFAULT_POST_LIST_LIMIT, + DEFAULT_POST_LIST_ORDER, + getClientListLimit, + getClientListOrder, + LIST_LIMIT_OPTIONS, + setClientListSettings, +} from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' +import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' import type { FC, FormEvent } from 'react' @@ -31,8 +43,40 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => { qs.set (k, t) } +const parseLimit = (value: string | null): 20 | 50 | 100 | null => { + const n = Number (value) + return n === 20 || n === 50 || n === 100 ? n : null +} + +const parseOrder = (value: string | null): FetchPostsOrder | null => { + switch (value) + { + case 'title:asc': + case 'title:desc': + case 'url:asc': + case 'url:desc': + case 'original_created_at:asc': + case 'original_created_at:desc': + case 'created_at:asc': + case 'created_at:desc': + case 'updated_at:asc': + case 'updated_at:desc': + return value + + default: + return null + } +} + const PostSearchPage: FC = () => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const thumbnailTransition = clientAnimationTransition ( + animationMode, + { normal: { type: 'spring', stiffness: 500, damping: 40, mass: .5 }, + reduced: { duration: .08, ease: 'linear' as const } }, + ) const location = useLocation () const navigate = useNavigate () @@ -41,7 +85,11 @@ const PostSearchPage: FC = () => { [location.search]) const page = Number (query.get ('page') ?? 1) - const limit = Number (query.get ('limit') ?? 20) + const limit = ( + parseLimit (query.get ('limit')) + ?? getClientListLimit ('postSearch') + ?? DEFAULT_POST_LIST_LIMIT + ) const qURL = query.get ('url') ?? '' const qTitle = query.get ('title') ?? '' @@ -53,7 +101,11 @@ const PostSearchPage: FC = () => { const qCreatedTo = query.get ('created_to') ?? '' const qUpdatedFrom = query.get ('updated_from') ?? '' const qUpdatedTo = query.get ('updated_to') ?? '' - const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder + const order = ( + parseOrder (query.get ('order')) + ?? getClientListOrder ('postSearch') + ?? DEFAULT_POST_LIST_ORDER + ) const [createdFrom, setCreatedFrom] = useState (null) const [createdTo, setCreatedTo] = useState (null) @@ -83,6 +135,10 @@ const PostSearchPage: FC = () => { const results = data?.posts ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 + useEffect (() => { + setClientListSettings ('postSearch', { limit, order }) + }, [limit, order]) + useEffect (() => { setURL (qURL ?? '') setTitle (qTitle ?? '') @@ -95,9 +151,11 @@ const PostSearchPage: FC = () => { setUpdatedFrom (qUpdatedFrom) setUpdatedTo (qUpdatedTo) - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom, - qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search, qCreatedFrom, qCreatedTo, qMatch, + qOriginalCreatedFrom, qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, + qUpdatedTo, qURL]) const search = async () => { const qs = new URLSearchParams () @@ -113,6 +171,22 @@ const PostSearchPage: FC = () => { qs.set ('match', matchType) qs.set ('page', '1') qs.set ('order', order) + qs.set ('limit', String (limit)) + navigate (`${ location.pathname }?${ qs.toString () }`) + } + + const updateListQuery = (next: { limit?: 20 | 50 | 100 + order?: FetchPostsOrder }) => { + const qs = new URLSearchParams (location.search) + qs.set ('limit', String (next.limit ?? limit)) + qs.set ('order', next.order ?? order) + qs.set ('page', '1') + navigate (`${ location.pathname }?${ qs.toString () }`) + } + + const updatePage = (nextPage: number) => { + const qs = new URLSearchParams (location.search) + qs.set ('page', String (Math.max (1, nextPage))) navigate (`${ location.pathname }?${ qs.toString () }`) } @@ -121,6 +195,23 @@ const PostSearchPage: FC = () => { search () } + useKeyboardShortcuts (useMemo (() => ({ + ...(page > 1 + ? { + 'pagination.previous': () => { + updatePage (page - 1) + }, + } + : { }), + ...(totalPages > 0 && page < totalPages + ? { + 'pagination.next': () => { + updatePage (Math.min (page + 1, totalPages)) + }, + } + : { }), + }), [page, totalPages, location.search])) + const defaultDirection = { title: 'asc', url: 'asc', original_created_at: 'desc', @@ -134,7 +225,24 @@ const PostSearchPage: FC = () => {
- 広場検索 +
+ 広場検索 + +
{/* タイトル */} @@ -142,6 +250,7 @@ const PostSearchPage: FC = () => { {({ invalid }) => ( setTitle (e.target.value)} className={inputClass (invalid)}/>)} @@ -161,6 +270,7 @@ const PostSearchPage: FC = () => { {() => ( )} @@ -296,15 +406,16 @@ const PostSearchPage: FC = () => { {results.map (row => ( - + + layoutId={animationMode === 'off' + ? undefined + : `page-${ row.id }`} + transition={thumbnailTransition}> {row.title ({ @@ -27,6 +28,7 @@ const scrollIntoView = vi.fn () vi.mock ('@/lib/api', () => api) vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, useDialogue: () => dialogue, })) diff --git a/frontend/src/pages/tags/NicoTagListPage.tsx b/frontend/src/pages/tags/NicoTagListPage.tsx index 5cbaadc..74f4d77 100644 --- a/frontend/src/pages/tags/NicoTagListPage.tsx +++ b/frontend/src/pages/tags/NicoTagListPage.tsx @@ -17,8 +17,10 @@ import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { apiPut } from '@/lib/api' import { extractValidationError } from '@/lib/apiErrors' +import { clientScrollBehaviour } from '@/lib/clientAnimation' import { tagsKeys } from '@/lib/queryKeys' import { fetchNicoTags } from '@/lib/tags' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn, dateString, inputClass } from '@/lib/utils' import { canEditContent } from '@/lib/users' @@ -38,6 +40,8 @@ const setIf = (qs: URLSearchParams, key: string, value: string) => { const NicoTagListPage: FC = ({ user }) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const dialogue = useDialogue () const location = useLocation () const navigate = useNavigate () @@ -152,8 +156,9 @@ const NicoTagListPage: FC = ({ user }) => { setLinkStatus (qLinkStatus) setEditingId (null) - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search, qLinkedTag, qLinkStatus, qName]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search, qLinkedTag, qLinkStatus, qName]) useEffect (() => { if (!(data)) diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx index 8ce1d1c..f95d2ae 100644 --- a/frontend/src/pages/tags/TagHistoryPage.tsx +++ b/frontend/src/pages/tags/TagHistoryPage.tsx @@ -11,8 +11,10 @@ import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { CATEGORY_NAMES } from '@/consts' import { apiPut } from '@/lib/api' +import { clientScrollBehaviour } from '@/lib/clientAnimation' import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { fetchTagChanges } from '@/lib/tags' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn, dateString } from '@/lib/utils' import type { FC } from 'react' @@ -43,6 +45,8 @@ const renderStateDiff = (diff: { current: string | null; prev: string | null }) const TagHistoryPage: FC = () => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const location = useLocation () const query = new URLSearchParams (location.search) const id = query.get ('id') @@ -58,8 +62,9 @@ const TagHistoryPage: FC = () => { const qc = useQueryClient () useEffect (() => { - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search]) return ( diff --git a/frontend/src/pages/tags/TagListPage.test.tsx b/frontend/src/pages/tags/TagListPage.test.tsx index f13f3eb..3d0e9ae 100644 --- a/frontend/src/pages/tags/TagListPage.test.tsx +++ b/frontend/src/pages/tags/TagListPage.test.tsx @@ -1,9 +1,10 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import TagListPage from '@/pages/tags/TagListPage' import { buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' +import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings' const tagsApi = vi.hoisted (() => ({ fetchTags: vi.fn (), @@ -12,6 +13,11 @@ const tagsApi = vi.hoisted (() => ({ vi.mock ('@/lib/tags', () => tagsApi) describe ('TagListPage', () => { + beforeEach (() => { + localStorage.clear () + vi.clearAllMocks () + }) + it ('loads tags from URL filters and renders the results table', async () => { tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [buildTag ({ @@ -38,6 +44,8 @@ describe ('TagListPage', () => { name: '虹', category: 'character', page: 3, + limit: 20, + order: 'post_count:desc', postCountGTE: 5, deprecated: true, }), @@ -48,7 +56,7 @@ describe ('TagListPage', () => { '/tags/7', ) expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0) - expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1') + expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1') expect (screen.getAllByText ('廃止')).toHaveLength (2) }) @@ -58,7 +66,7 @@ describe ('TagListPage', () => { renderWithProviders (, { route: '/tags' }) fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } }) - fireEvent.change (screen.getAllByRole ('combobox')[0], { + fireEvent.change (screen.getAllByRole ('combobox')[1], { target: { value: 'character' }, }) fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!) @@ -82,4 +90,41 @@ describe ('TagListPage', () => { expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () }) + + it ('uses URL limit before localStorage and preserves the query order', async () => { + localStorage.setItem ( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify ({ lists: { tagList: { limit: 100, order: 'name:asc' } } })) + tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [], count: 0 }) + + renderWithProviders ( + , + { route: '/tags?limit=50&order=updated_at%3Adesc' }, + ) + + await waitFor (() => { + expect (tagsApi.fetchTags).toHaveBeenCalledWith ( + expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }), + ) + }) + expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) + .toMatchObject ({ lists: { tagList: { limit: 50, order: 'updated_at:desc' } } }) + }) + + it ('keeps the compact limit control outside the search form and resets page on change', async () => { + tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 }) + + renderWithProviders (, { route: '/tags?page=4' }) + + const limitSelect = await screen.findByLabelText ('件数') + expect (limitSelect.closest ('form')).toBeNull () + + fireEvent.change (limitSelect, { target: { value: '100' } }) + + await waitFor (() => { + expect (tagsApi.fetchTags).toHaveBeenLastCalledWith ( + expect.objectContaining ({ page: 1, limit: 100 }), + ) + }) + }) }) diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index f39c58a..568c5d6 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -13,7 +13,18 @@ import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { CATEGORIES, CATEGORY_NAMES } from '@/consts' +import { clientScrollBehaviour } from '@/lib/clientAnimation' import { tagsKeys } from '@/lib/queryKeys' +import { + DEFAULT_POST_LIST_LIMIT, + DEFAULT_TAG_LIST_ORDER, + getClientListLimit, + getClientListOrder, + LIST_LIMIT_OPTIONS, + setClientListSettings, +} from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' +import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { fetchTags } from '@/lib/tags' import { dateString, inputClass } from '@/lib/utils' @@ -35,8 +46,35 @@ const boolFromQuery = (value: string | null): boolean => const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : '' +const parseLimit = (value: string | null): 20 | 50 | 100 | null => { + const n = Number (value) + return n === 20 || n === 50 || n === 100 ? n : null +} + +const parseOrder = (value: string | null): FetchTagsOrder | null => { + switch (value) + { + case 'name:asc': + case 'name:desc': + case 'category:asc': + case 'category:desc': + case 'post_count:asc': + case 'post_count:desc': + case 'created_at:asc': + case 'created_at:desc': + case 'updated_at:asc': + case 'updated_at:desc': + return value + + default: + return null + } +} + const TagListPage: FC = () => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' const location = useLocation () const navigate = useNavigate () @@ -44,7 +82,11 @@ const TagListPage: FC = () => { const query = useMemo (() => new URLSearchParams (location.search), [location.search]) const page = Number (query.get ('page') ?? 1) - const limit = Number (query.get ('limit') ?? 20) + const limit = ( + parseLimit (query.get ('limit')) + ?? getClientListLimit ('tagList') + ?? DEFAULT_POST_LIST_LIMIT + ) const qName = query.get ('name') ?? '' const qCategory = (query.get ('category') || null) as Category | null @@ -58,7 +100,11 @@ const TagListPage: FC = () => { const qDeprecated = query.has ('deprecated') ? boolFromQuery (query.get ('deprecated')) : null - const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder + const order = ( + parseOrder (query.get ('order')) + ?? getClientListOrder ('tagList') + ?? DEFAULT_TAG_LIST_ORDER + ) const [name, setName] = useState ('') const [category, setCategory] = useState (null) @@ -88,6 +134,10 @@ const TagListPage: FC = () => { const results = data?.tags ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 + useEffect (() => { + setClientListSettings ('tagList', { limit, order }) + }, [limit, order]) + useEffect (() => { setName (qName) setCategory (qCategory) @@ -99,9 +149,11 @@ const TagListPage: FC = () => { setUpdatedTo (qUpdatedTo) setDeprecated (qDeprecated) - document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) - }, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE, - qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated]) + document.querySelector ('table')?.scrollIntoView ({ + behavior: clientScrollBehaviour (animationMode) }) + }, [animationMode, location.search, qCategory, qCreatedFrom, qCreatedTo, + qName, qPostCountGTE, qPostCountLTE, qUpdatedFrom, qUpdatedTo, + qDeprecated]) const handleSearch = (e: FormEvent) => { e.preventDefault () @@ -121,16 +173,49 @@ const TagListPage: FC = () => { qs.set ('deprecated', deprecated ? '1' : '0') qs.set ('page', '1') qs.set ('order', order) + qs.set ('limit', String (limit)) navigate (`${ location.pathname }?${ qs.toString () }`) } + const updateListQuery = (next: { limit?: 20 | 50 | 100 + order?: FetchTagsOrder }) => { + const qs = new URLSearchParams (location.search) + qs.set ('limit', String (next.limit ?? limit)) + qs.set ('order', next.order ?? order) + qs.set ('page', '1') + navigate (`${ location.pathname }?${ qs.toString () }`) + } + + const updatePage = (nextPage: number) => { + const qs = new URLSearchParams (location.search) + qs.set ('page', String (Math.max (1, nextPage))) + navigate (`${ location.pathname }?${ qs.toString () }`) + } + const defaultDirection = { name: 'asc', category: 'asc', post_count: 'desc', created_at: 'desc', updated_at: 'desc' } as const + useKeyboardShortcuts (useMemo (() => ({ + ...(page > 1 + ? { + 'pagination.previous': () => { + updatePage (page - 1) + }, + } + : { }), + ...(totalPages > 0 && page < totalPages + ? { + 'pagination.next': () => { + updatePage (Math.min (page + 1, totalPages)) + }, + } + : { }), + }), [page, totalPages, location.search])) + return ( @@ -138,7 +223,24 @@ const TagListPage: FC = () => {
- タグ +
+ タグ + +
{/* 名前 */} @@ -146,6 +248,7 @@ const TagListPage: FC = () => { {({ invalid }) => ( setName (e.target.value)} className={inputClass (invalid)}/>)} diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx index 745ea4e..2023a4e 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx @@ -41,6 +41,7 @@ const postEmbed = vi.hoisted (() => ({ vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, useDialogue: () => dialogue, })) vi.mock ('@/components/PostEmbed', () => ({ diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index c414048..1c99361 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -15,7 +15,16 @@ import { Button } from '@/components/ui/button' import { SITE_TITLE } from '@/config' import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' +import { clientAnimationTransition, + clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchPost } from '@/lib/posts' +import { + getClientTheatreLayoutMode, + getClientTheatreTagFlow, + setClientTheatreLayoutMode, + setClientTheatreTagFlow, +} from '@/lib/settings' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { canEditContent } from '@/lib/users' import { cn, dateString, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' @@ -51,8 +60,6 @@ const INITIAL_THEATRE_INFO: TheatreInfo = const INITIAL_WEIGHTS: TheatrePostSelectionWeights = { tagPenalties: [], lightestPosts: [], heaviestPosts: [] } -const LAYOUT_STORAGE_KEY = 'theatre-layout-mode' -const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const MIN_MAIN_WIDTH = 360 const SIDEBAR_GAP_WIDTH = 16 @@ -208,6 +215,12 @@ type Props = { user: User | null } const TheatreDetailPage: FC = ({ user }: Props) => { + const behaviourSettings = useClientBehaviourSettings () + const animationMode = behaviourSettings.animation ?? 'normal' + const layoutTransition = clientAnimationTransition ( + animationMode, + { normal: { duration: .2, ease: 'easeOut' as const } }, + ) const { id } = useParams () const dialogue = useDialogue () @@ -236,32 +249,20 @@ const TheatreDetailPage: FC = ({ user }: Props) => { const [post, setPost] = useState (null) const [videoLength, setVideoLength] = useState (0) const [weights, setWeights] = useState (INITIAL_WEIGHTS) - const [layoutMode, setLayoutMode] = useState (() => { - const stored = localStorage.getItem (LAYOUT_STORAGE_KEY) - return ( - ((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[]) - .includes (stored as TheatreLayoutMode)) - ? (stored as TheatreLayoutMode) - : 'threeColumns') - }) - const [tagFlow, setTagFlow] = useState (() => { - const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY) - return ( - (['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow) - ? (stored as TagFlow) - : 'vertical') - }) + const [layoutMode, setLayoutMode] = + useState (() => getClientTheatreLayoutMode ()) + const [tagFlow, setTagFlow] = useState (() => getClientTheatreTagFlow ()) const { fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () const changeLayoutMode = (mode: TheatreLayoutMode) => { setLayoutMode (mode) - localStorage.setItem (LAYOUT_STORAGE_KEY, mode) + setClientTheatreLayoutMode (mode) } const changeTagFlow = (flow: TagFlow) => { setTagFlow (flow) - localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow) + setClientTheatreTagFlow (flow) } const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { @@ -626,7 +627,9 @@ const TheatreDetailPage: FC = ({ user }: Props) => { clearValidationErrors () await apiPost (`/theatres/${ id }/comments`, { content }) setContent ('') - commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) + commentsRef.current?.scrollTo ({ + top: 0, + behavior: clientScrollBehaviour (animationMode) }) } catch (error) { @@ -843,8 +846,8 @@ const TheatreDetailPage: FC = ({ user }: Props) => { return ( @@ -868,7 +871,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { )}
diff --git a/frontend/src/pages/users/SettingPage.test.tsx b/frontend/src/pages/users/SettingPage.test.tsx index 0595642..d88798a 100644 --- a/frontend/src/pages/users/SettingPage.test.tsx +++ b/frontend/src/pages/users/SettingPage.test.tsx @@ -6,6 +6,7 @@ import { buildUser } from '@/test/factories' import { renderWithProviders } from '@/test/render' const api = vi.hoisted (() => ({ + apiGet: vi.fn (), apiPut: vi.fn (), isApiError: vi.fn (), })) @@ -27,12 +28,23 @@ describe ('SettingPage', () => { beforeEach (() => { vi.clearAllMocks () api.isApiError.mockReturnValue (false) + api.apiGet.mockImplementation ((path: string) => { + if (path === '/users/settings') + return Promise.resolve ({ + theme: 'system', + auto_fetch_title: 'manual', + auto_fetch_thumbnail: 'manual', + wiki_editor_mode: 'split' }) + if (path === '/users/theme_slots') + return Promise.resolve ([]) + return Promise.resolve ({}) + }) }) it ('shows loading when user is absent', () => { renderWithProviders () - expect (screen.getByText ('Loading...')).toBeInTheDocument () + expect (screen.getAllByText ('Loading...').length).toBeGreaterThan (0) }) it ('updates the current user name', async () => { @@ -42,8 +54,9 @@ describe ('SettingPage', () => { renderWithProviders () - fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'new' } }) - fireEvent.click (screen.getByRole ('button', { name: '更新' })) + const nameInput = (await screen.findAllByRole ('textbox'))[0] + fireEvent.change (nameInput, { target: { value: 'new' } }) + fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0]) await waitFor (() => { expect (api.apiPut).toHaveBeenCalledWith ( @@ -55,7 +68,7 @@ describe ('SettingPage', () => { const formData = api.apiPut.mock.calls[0]?.[1] as FormData expect (formData.get ('name')).toBe ('new') expect (setUser).toHaveBeenCalled () - expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' }) + expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' }) }) it ('shows validation errors returned for the name field', async () => { @@ -75,10 +88,11 @@ describe ('SettingPage', () => { renderWithProviders () - fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } }) - fireEvent.click (screen.getByRole ('button', { name: '更新' })) + const nameInput = (await screen.findAllByRole ('textbox'))[0] + fireEvent.change (nameInput, { target: { value: '' } }) + fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0]) - expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument () - expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true') + expect ((await screen.findAllByText ('名前は必須です.')).length).toBeGreaterThan (0) + expect (nameInput).toHaveAttribute ('aria-invalid', 'true') }) }) diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index a271da9..5619a6e 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,43 +1,1026 @@ -import type { FC } from 'react' - -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' +import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom' import FieldError from '@/components/common/FieldError' -import Form from '@/components/common/Form' import FormField from '@/components/common/FormField' import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' -import InheritDialogue from '@/components/users/InheritDialogue' -import UserCodeDialogue from '@/components/users/UserCodeDialogue' +import TagLink from '@/components/TagLink' +import { useDialogue } from '@/components/dialogues/DialogueProvider' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' +import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection' +import InheritDialogue from '@/components/users/InheritDialogue' +import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection' +import UserCodeDialogue from '@/components/users/UserCodeDialogue' +import { CATEGORY_NAMES, CATEGORIES } from '@/consts' import { SITE_TITLE } from '@/config' import { apiPut } from '@/lib/api' -import { inputClass } from '@/lib/utils' +import { + applyClientAppearance, + applyThemeAppearanceState, + buildThemeTokens, + fetchUserSettings, + fetchUserThemeSlots, + getClientActiveDarkThemeSlotNo, + getClientActiveLightThemeSlotNo, + getClientThemeMode, + getDefaultUserThemeSlot, + hasStoredClientThemeSelection, + normaliseUserThemeSlots, + resolveDisplayedTheme, + setCachedUserThemeSlots, + setClientThemeAppearance, + upsertUserThemeSlot, + USER_THEME_SLOT_SELECTIONS, +} from '@/lib/settings' +import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard' +import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' +import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' -import type { User } from '@/types' +import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react' -type Props = { user: User | null - setUser: React.Dispatch> } +import type { Category, Tag, User } from '@/types' +import type { + BuiltinThemeId, + ClientThemeMode, + ClientTheme, + ThemeTokens, + ThemeTagColours, + UserThemeSlotMap, + UserThemeSlotNo, + UserThemeSlotSelection, +} from '@/lib/settings' + +type Props = { + user: User | null + setUser: Dispatch> } type UserFormField = 'name' +type SettingsFormField = 'theme' +type SettingsTab = 'account' | 'behavior' | 'theme' | 'keyboard' +type ThemeTokenKey = Exclude + +type TabSpec = { + id: SettingsTab + label: string } + +type AccountSectionProps = { + user: User + name: string + onNameChange: (value: string) => void + onInheritOpen: () => void + onUserCodeOpen: () => void + onUserSubmit: () => void + userBaseErrors: string[] + userFieldErrors: Partial> } + +type ThemeModeItem = { + id: ClientThemeMode + label: string } + +type ThemeSelectionItem = { + id: UserThemeSlotSelection + label: string + baseTheme: BuiltinThemeId + slotNo: UserThemeSlotNo } + +type ThemeTokenFieldSpec = { + key: ThemeTokenKey + label: string } + +type ThemeTokenGroupSpec = { + title: string + fields: ThemeTokenFieldSpec[] } + +type ThemeSectionProps = { + draftThemeMode: ClientThemeMode + draftActiveLightThemeSlotNo: UserThemeSlotNo + draftActiveDarkThemeSlotNo: UserThemeSlotNo + currentDisplayedThemeSlot: UserThemeSlotSelection + selectedTheme: ClientTheme + displayedThemeLabel: string + systemThemeLabel: string + hasUnsavedChanges: boolean + settingsBaseErrors: string[] + settingsFieldErrors: Partial> + onSelectThemeMode: (themeMode: ClientThemeMode) => void + onSelectLightThemeSlotNo: (slotNo: UserThemeSlotNo) => void + onSelectDarkThemeSlotNo: (slotNo: UserThemeSlotNo) => void + onThemeTokenChange: (key: ThemeTokenKey, value: string) => void + onTagColourChange: (category: Category, colour: string) => void + onResetThemeSlot: () => void + onThemeSubmit: () => void + onThemeDiscard: () => void } + +type SettingsTabDirtyState = { + dirty: boolean + discard: () => void | Promise } + +type ThemeDraftState = { + themeMode: ClientThemeMode + activeLightThemeSlotNo: UserThemeSlotNo + activeDarkThemeSlotNo: UserThemeSlotNo + themeSlots: UserThemeSlotMap } + +type ThemeSegmentedOption = { + value: T + label: string } + +type ThemeSegmentedControlProps = { + value: T + options: ThemeSegmentedOption[] + onChange: (value: T) => void } + +const tabs: TabSpec[] = [ + { id: 'account', label: 'アカウント' }, + { id: 'behavior', label: '動作' }, + { id: 'theme', label: 'テーマ' }, + { id: 'keyboard', label: 'キーボード' } ] + +const themeModeSelections: ThemeModeItem[] = [ + { id: 'system', label: 'システム設定に従う' }, + { id: 'light', label: 'ライト' }, + { id: 'dark', label: 'ダーク' } ] + +const themeSlotNoSelections: ThemeSegmentedOption[] = [ + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 3, label: '3' } ] + +const sectionClassName = + 'space-y-4 rounded-xl p-4' + +const themeSelections: ThemeSelectionItem[] = [ + { id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 }, + { id: 'user:light:2', label: 'ライト 2', baseTheme: 'light', slotNo: 2 }, + { id: 'user:light:3', label: 'ライト 3', baseTheme: 'light', slotNo: 3 }, + { id: 'user:dark:1', label: 'ダーク 1', baseTheme: 'dark', slotNo: 1 }, + { id: 'user:dark:2', label: 'ダーク 2', baseTheme: 'dark', slotNo: 2 }, + { id: 'user:dark:3', label: 'ダーク 3', baseTheme: 'dark', slotNo: 3 }, +] + +const themeTokenGroups: ThemeTokenGroupSpec[] = [ + { title: '基本', + fields: [ + { key: 'background', label: '背景' }, + { key: 'foreground', label: '文字' }, + { key: 'link', label: 'リンク' }] }] + + +const ThemeSegmentedControl = ( + { value, options, onChange }: ThemeSegmentedControlProps, +) => ( +
+ {options.map (option => ( + ))} +
) + +const previewTag = ( + id: number, + name: string, + category: Category, + postCount: number, + hasWiki: boolean, + options?: { hasDeerjikists?: boolean }, +): Tag => ({ + id, + name, + category, + deprecatedAt: null, + aliases: [], + parents: [], + postCount, + createdAt: '', + updatedAt: '', + hasWiki, + materialId: null, + hasDeerjikists: options?.hasDeerjikists ?? false, + children: [] }) + +const previewTags: Record = { + deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }), + meme: previewTag (2, 'ミーム', 'meme', 34, true), + character: previewTag (3, 'キャラクター', 'character', 56, false), + general: previewTag (4, '一般', 'general', 78, true), + material: previewTag (5, '素材', 'material', 90, false), + meta: previewTag (6, 'メタ', 'meta', 21, true), + nico: previewTag (7, 'nico:sm9', 'nico', 43, false) } + +const parseTab = (search: string): SettingsTab => { + const tab = new URLSearchParams (search).get ('tab') + return (tabs.some (candidate => candidate.id === tab) + ? (tab as SettingsTab) + : 'account') +} + +const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }` +const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }` + +const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties => + Object.fromEntries ( + CATEGORIES.flatMap (category => [ + [`--tag-colour-${ category }`, tagColours[category]], + [`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties + +const themeLabelBySelection = (selection: UserThemeSlotSelection): string => + themeSelections.find (item => item.id === selection)?.label ?? 'ライト 1' + +const ensureCompleteThemeSlots = ( + themeSlots: UserThemeSlotMap, +): UserThemeSlotMap => + USER_THEME_SLOT_SELECTIONS.reduce ( + (complete, selection) => { + const [_, baseTheme, slotNo] = selection.split (':') + complete[selection] = + themeSlots[selection] + ?? getDefaultUserThemeSlot ( + baseTheme as BuiltinThemeId, + Number (slotNo) as UserThemeSlotNo, + ) + return complete + }, + { ...themeSlots }, + ) + +const comparableThemeTokensForSelection = ( + themeSlots: UserThemeSlotMap, + selection: UserThemeSlotSelection, +): ThemeTokens => { + const [_, baseTheme, slotNo] = selection.split (':') + const fallback = + getDefaultUserThemeSlot ( + baseTheme as BuiltinThemeId, + Number (slotNo) as UserThemeSlotNo, + ).tokens + + return { + ...fallback, + ...(themeSlots[selection]?.tokens ?? { }), + tagColours: { + ...fallback.tagColours, + ...(themeSlots[selection]?.tokens.tagColours ?? { }), + }, + } +} + +const serialiseThemeTokens = ( + themeSlots: UserThemeSlotMap, + selection: UserThemeSlotSelection, +): string => + JSON.stringify (comparableThemeTokensForSelection (themeSlots, selection)) + + +const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean => + key.startsWith ('topNav') + + +const formatHslComponent = (value: number): string => + Number.isInteger (value) ? String (value) : value.toFixed (1).replace (/\.0$/, '') + + +const hslTokenToHex = (token: string): string => { + const match = + token.match ( + /^(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/, + ) + if (match == null) + return '#000000' + + const hue = Number (match[1]) / 360 + const saturation = Number (match[2]) / 100 + const lightness = Number (match[3]) / 100 + + if (saturation === 0) + { + const channel = Math.round (lightness * 255) + const hex = channel.toString (16).padStart (2, '0') + return `#${ hex }${ hex }${ hex }` + } + + const hueToRgb = ( + p: number, + q: number, + t: number, + ): number => { + let adjusted = t + + if (adjusted < 0) + adjusted += 1 + if (adjusted > 1) + adjusted -= 1 + if (adjusted < 1 / 6) + return p + (q - p) * 6 * adjusted + if (adjusted < 1 / 2) + return q + if (adjusted < 2 / 3) + return p + (q - p) * (2 / 3 - adjusted) * 6 + return p + } + + const q = + lightness < .5 + ? lightness * (1 + saturation) + : lightness + saturation - lightness * saturation + const p = 2 * lightness - q + const red = Math.round (hueToRgb (p, q, hue + 1 / 3) * 255) + const green = Math.round (hueToRgb (p, q, hue) * 255) + const blue = Math.round (hueToRgb (p, q, hue - 1 / 3) * 255) + + return `#${ [red, green, blue] + .map (channel => channel.toString (16).padStart (2, '0')) + .join ('') }` +} + + +const hexToHslToken = (hex: string): string => { + const red = parseInt (hex.slice (1, 3), 16) / 255 + const green = parseInt (hex.slice (3, 5), 16) / 255 + const blue = parseInt (hex.slice (5, 7), 16) / 255 + const max = Math.max (red, green, blue) + const min = Math.min (red, green, blue) + const lightness = (max + min) / 2 + + if (max === min) + return `0 0% ${ formatHslComponent (lightness * 100) }%` + + const delta = max - min + const saturation = + lightness > .5 + ? delta / (2 - max - min) + : delta / (max + min) + + let hue = 0 + switch (max) + { + case red: + hue = (green - blue) / delta + (green < blue ? 6 : 0) + break + case green: + hue = (blue - red) / delta + 2 + break + case blue: + hue = (red - green) / delta + 4 + break + } + + return [ + formatHslComponent (hue * 60), + `${ formatHslComponent (saturation * 100) }%`, + `${ formatHslComponent (lightness * 100) }%`, + ].join (' ') +} + + +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 = ( + { name, + onInheritOpen, + onNameChange, + onUserCodeOpen, + onUserSubmit, + user, + userBaseErrors, + userFieldErrors }, +) => ( +
+

アカウント

+ + + + {({ describedBy, invalid }) => ( + <> + onNameChange (ev.target.value)}/> + {!(user.name) && ( +

+ 名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!! +

)} + )} +
+ +
+ +
+ +
+ +
+ + +
+
+
) + +const ThemeSection: FC = ( + { draftThemeMode, + draftActiveLightThemeSlotNo, + draftActiveDarkThemeSlotNo, + currentDisplayedThemeSlot, + selectedTheme, + hasUnsavedChanges, + settingsBaseErrors, + settingsFieldErrors, + onSelectThemeMode, + onSelectLightThemeSlotNo, + onSelectDarkThemeSlotNo, + onThemeTokenChange, + onTagColourChange, + onResetThemeSlot, + onThemeSubmit, + onThemeDiscard }, +) => { + return ( +
+
+
+

テーマ

+ {hasUnsavedChanges && ( +

+ 未保存の変更があります +

)} +
+ +
+ + + + +
+
+ + + + +
+
+
+ +
+ ({ + value: selection.id, + label: selection.label }))} + onChange={value => onSelectThemeMode (value as ClientThemeMode)}/> +
+ +
+
+
+ + +
+ +
+ + +
+
+
+
+ +
+ {themeTokenGroups.map (group => ( +
+

{group.title}

+
+ {group.fields.map (field => ( +
+

{field.label}

+
+ + onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))} + className="h-9 w-14 cursor-pointer rounded border border-border + bg-transparent p-1 disabled:cursor-not-allowed + disabled:opacity-50"/> +
+
))} +
+
))} + +
+
+
+

タグ

+
+
+ +
+ {CATEGORIES.map (category => ( +
+
+

{CATEGORY_NAMES[category]}

+
+ +
+
+ +
+ onTagColourChange (category, ev.target.value)} + className="h-9 w-14 cursor-pointer rounded border border-border + bg-transparent p-1 disabled:cursor-not-allowed + disabled:opacity-50"/> +
+
))} +
+
+
+
) +} const SettingPage: FC = ({ user, setUser }) => { - const [name, setName] = useState ('') - const [userCodeVsbl, setUserCodeVsbl] = useState (false) - const [inheritVsbl, setInheritVsbl] = useState (false) - const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = - useValidationErrors () + const location = useLocation () + const navigate = useNavigate () + const dialogue = useDialogue () + const { registerUnsavedChangesSource } = useUnsavedChangesGuard () - const handleSubmit = async () => { + const [inheritVsbl, setInheritVsbl] = useState (false) + const [loading, setLoading] = useState (true) + const [name, setName] = useState ('') + const [settingsError, setSettingsError] = useState (null) + const [savedThemeMode, setSavedThemeMode] = + useState (getClientThemeMode ()) + const [draftThemeMode, setDraftThemeMode] = + useState (getClientThemeMode ()) + const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] = + useState (getClientActiveLightThemeSlotNo ()) + const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] = + useState (getClientActiveLightThemeSlotNo ()) + const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] = + useState (getClientActiveDarkThemeSlotNo ()) + const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] = + useState (getClientActiveDarkThemeSlotNo ()) + const [savedThemeSlots, setSavedThemeSlots] = + useState (() => ensureCompleteThemeSlots ({ })) + const [draftThemeSlots, setDraftThemeSlots] = + useState (() => ensureCompleteThemeSlots ({ })) + const [dirtyStates, setDirtyStates] = + useState>> ({ }) + const [userCodeVsbl, setUserCodeVsbl] = useState (false) + const { conflictActionIds } = useKeyboardShortcutSettings () + const { baseErrors: userBaseErrors, + fieldErrors: userFieldErrors, + clearValidationErrors: clearUserValidationErrors, + applyValidationError: applyUserValidationError } = useValidationErrors () + const { baseErrors: settingsBaseErrors, + fieldErrors: settingsFieldErrors, + clearValidationErrors: clearSettingsValidationErrors, + applyValidationError: applySettingsValidationError } = + useValidationErrors () + + const activeTab = useMemo (() => parseTab (location.search), [location.search]) + const displayedThemeSelection = useMemo ( + () => resolveDisplayedTheme ({ + themeMode: draftThemeMode, + activeLightThemeSlotNo: draftActiveLightThemeSlotNo, + activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots), + [draftActiveDarkThemeSlotNo, + draftActiveLightThemeSlotNo, + draftThemeMode, + draftThemeSlots]) + const selectedTheme = useMemo ( + () => { + const currentSelection = + themeSelections.find ( + selection => selection.id === displayedThemeSelection.selection, + ) + ?? themeSelections[0] + const themeSlot = + draftThemeSlots[currentSelection.id] + ?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo) + return { + id: currentSelection.baseTheme, + name: themeLabelBySelection (currentSelection.id), + builtin: false, + baseTheme: currentSelection.baseTheme, + tokens: themeSlot.tokens } + }, + [displayedThemeSelection.selection, draftThemeSlots]) + const hasUnsavedThemeChanges = useMemo ( + () => + serialiseThemeTokens (draftThemeSlots, displayedThemeSelection.selection) + !== serialiseThemeTokens (savedThemeSlots, displayedThemeSelection.selection), + [displayedThemeSelection.selection, draftThemeSlots, savedThemeSlots]) + const savedName = user?.name ?? '' + const hasUnsavedAccountChanges = name !== savedName + const hasPageUnsavedChanges = Object.values (dirtyStates).some (state => state.dirty) + + const applyActiveTab = (tab: SettingsTab) => { + const params = new URLSearchParams (location.search) + params.set ('tab', tab) + navigate ( + `${ location.pathname }?${ params.toString () }`, + { replace: true }) + } + + const updateTabDirtyState = ( + tab: SettingsTab, + state: SettingsTabDirtyState, + ) => { + setDirtyStates (current => { + if (!(state.dirty) && current[tab] == null) + return 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 => + dialogue.confirm ({ + title: '未保存の変更があります', + description, + confirmText, + cancelText, + variant: 'danger' }) + + const settingsTabErrors = useMemo> ( + () => ( + { account: Boolean (userFieldErrors.name?.length), + behavior: false, + theme: Boolean (settingsFieldErrors.theme?.length), + keyboard: conflictActionIds.size > 0 }), + [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name]) + + const applyDraftThemeState = ( + { themeMode = draftThemeMode, + activeLightThemeSlotNo = draftActiveLightThemeSlotNo, + activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo, + themeSlots = draftThemeSlots, + }: { themeMode?: ClientThemeMode + activeLightThemeSlotNo?: UserThemeSlotNo + activeDarkThemeSlotNo?: UserThemeSlotNo + themeSlots?: UserThemeSlotMap }, + ) => { + setDraftThemeMode (themeMode) + setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo) + setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo) + setDraftThemeSlots (themeSlots) + setCachedUserThemeSlots (themeSlots) + applyThemeAppearanceState ({ + themeMode, + activeLightThemeSlotNo, + activeDarkThemeSlotNo }, themeSlots) + } + + const discardAccountChanges = () => { + setName (savedName) + clearUserValidationErrors () + } + + const currentThemeDraftState = (): ThemeDraftState => ({ + themeMode: draftThemeMode, + activeLightThemeSlotNo: draftActiveLightThemeSlotNo, + activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, + themeSlots: draftThemeSlots }) + + const savedThemeDraftState = (): ThemeDraftState => ({ + themeMode: savedThemeMode, + activeLightThemeSlotNo: savedActiveLightThemeSlotNo, + activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo, + themeSlots: savedThemeSlots }) + + const discardThemeChanges = () => { + const selection = displayedThemeSelection.selection + const [_, baseTheme, slotNo] = selection.split (':') + const nextThemeSlots = { + ...draftThemeSlots, + [selection]: savedThemeSlots[selection] + ?? getDefaultUserThemeSlot ( + baseTheme as BuiltinThemeId, + Number (slotNo) as UserThemeSlotNo, + ), + } + + setDraftThemeSlots (nextThemeSlots) + setCachedUserThemeSlots (nextThemeSlots) + applyThemeAppearanceState ({ + themeMode: draftThemeMode, + activeLightThemeSlotNo: draftActiveLightThemeSlotNo, + activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo, + }, nextThemeSlots) + } + + const discardAllDirtyChanges = useCallback (async (): Promise => { + const currentDirtyStates = Object.values (dirtyStates) + + for (const dirtyState of currentDirtyStates) + { + if (dirtyState?.dirty) + await dirtyState.discard () + } + + setDirtyStates ({ }) + }, [dirtyStates]) + + const discardCurrentTabChanges = async (): Promise => { + const currentDirtyState = dirtyStates[activeTab] + + if (currentDirtyState?.dirty) + await currentDirtyState.discard () + + setDirtyStates ({ }) + } + + const requestActiveTab = async ( + nextTab: SettingsTab, + ): Promise => { + if (nextTab === activeTab) + return + + const currentDirtyState = dirtyStates[activeTab] + if (!(currentDirtyState?.dirty)) + { + applyActiveTab (nextTab) + return + } + + const confirmed = await confirmDiscardChanges ( + 'このタブを離れると、保存していない変更は失われます。', + 'このタブに残る', + '変更を破棄して移動') + if (!(confirmed)) + return + + await discardCurrentTabChanges () + applyActiveTab (nextTab) + } + + const requestThemeStateChange = async ( + buildNextState: (baseState: ThemeDraftState) => ThemeDraftState, + description = 'このまま切り替えると、保存していない変更は失われます。', + cancelText = 'このまま編集する', + confirmText = '変更を破棄して切り替える', + ): Promise => { + const baseState = currentThemeDraftState () + let themeSlots = baseState.themeSlots + + if (hasUnsavedThemeChanges) + { + const confirmed = await confirmDiscardChanges ( + description, + cancelText, + confirmText) + if (!(confirmed)) + return + + const selection = displayedThemeSelection.selection + const [_, baseTheme, slotNo] = selection.split (':') + themeSlots = { + ...baseState.themeSlots, + [selection]: savedThemeSlots[selection] + ?? getDefaultUserThemeSlot ( + baseTheme as BuiltinThemeId, + Number (slotNo) as UserThemeSlotNo, + ), + } + } + + const nextState = buildNextState ( + hasUnsavedThemeChanges + ? { ...savedThemeDraftState (), themeSlots } + : { ...baseState, themeSlots }) + + setSavedThemeMode (nextState.themeMode) + setSavedActiveLightThemeSlotNo (nextState.activeLightThemeSlotNo) + setSavedActiveDarkThemeSlotNo (nextState.activeDarkThemeSlotNo) + setClientThemeAppearance ({ + themeMode: nextState.themeMode, + activeLightThemeSlotNo: nextState.activeLightThemeSlotNo, + activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo, + }) + applyDraftThemeState ({ + themeMode: nextState.themeMode, + activeLightThemeSlotNo: nextState.activeLightThemeSlotNo, + activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo, + themeSlots: nextState.themeSlots }) + } + + const requestThemeMode = async ( + themeMode: ClientThemeMode, + ): Promise => { + if (themeMode === draftThemeMode) + return + + await requestThemeStateChange (baseState => ({ ...baseState, themeMode })) + } + + const requestLightThemeSlotNo = async ( + slotNo: UserThemeSlotNo, + ): Promise => { + if (slotNo === draftActiveLightThemeSlotNo) + return + + await requestThemeStateChange (baseState => ({ + ...baseState, + activeLightThemeSlotNo: slotNo })) + } + + const requestDarkThemeSlotNo = async ( + slotNo: UserThemeSlotNo, + ): Promise => { + if (slotNo === draftActiveDarkThemeSlotNo) + return + + await requestThemeStateChange (baseState => ({ + ...baseState, + activeDarkThemeSlotNo: slotNo })) + } + + useBeforeUnload (event => { + if (!(hasPageUnsavedChanges)) + return + + event.preventDefault () + event.returnValue = '' + }, { capture: true }) + + const updateDraftThemeSlot = ( + updater: (tokens: ThemeTokens) => ThemeTokens, + ) => { + const selection = displayedThemeSelection.selection + const fallbackThemeSlot = + getDefaultUserThemeSlot ( + displayedThemeSelection.baseTheme, + displayedThemeSelection.slotNo, + ) + const currentThemeSlot = + draftThemeSlots[selection] + ?? fallbackThemeSlot + const nextTokens = buildThemeTokens ( + currentThemeSlot.baseTheme, + updater (currentThemeSlot.tokens), + ) + const nextThemeSlots = { + ...draftThemeSlots, + [selection]: { ...currentThemeSlot, tokens: nextTokens } } + + applyDraftThemeState ({ themeSlots: nextThemeSlots }) + } + + const handleTabKeyDown = (event: KeyboardEvent) => { + const currentIndex = tabs.findIndex (tab => tab.id === activeTab) + + switch (event.key) + { + case 'ArrowDown': + case 'ArrowRight': + event.preventDefault () + requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id) + break + + case 'ArrowUp': + case 'ArrowLeft': + event.preventDefault () + requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id) + break + + case 'Home': + event.preventDefault () + requestActiveTab (tabs[0].id) + break + + case 'End': + event.preventDefault () + requestActiveTab (tabs[tabs.length - 1].id) + break + } + } + + const handleUserSubmit = async () => { if (!(user)) return - clearValidationErrors () + clearUserValidationErrors () const formData = new FormData formData.append ('name', name) @@ -47,16 +1030,57 @@ const SettingPage: FC = ({ user, setUser }) => { const data = await apiPut ( `/users/${ user.id }`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }) - setUser (user => ({ ...user, ...data })) - toast ({ title: '設定を更新しました.' }) + setUser (currentUser => ({ ...currentUser, ...data })) + setName (data.name ?? '') + toast ({ title: '表示名を更新しました.' }) } - catch (e) + catch (submitError) { - applyValidationError (e) - toast ({ title: 'しっぱい……' }) + applyUserValidationError (submitError) + toast ({ title: '表示名を更新できませんでした.' }) } } + const handleThemeSubmit = async () => { + clearSettingsValidationErrors () + + try + { + const selection = displayedThemeSelection.selection + let nextSavedThemeSlots = { ...savedThemeSlots } + const draftThemeSlot = draftThemeSlots[selection] + const savedThemeSlot = savedThemeSlots[selection] + + if (JSON.stringify (draftThemeSlot?.tokens) !== JSON.stringify (savedThemeSlot?.tokens)) + { + const persisted = await upsertUserThemeSlot ( + draftThemeSlot?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId, + draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo, + draftThemeSlot?.tokens ?? getDefaultUserThemeSlot ( + selection.split (':')[1] as BuiltinThemeId, + Number (selection.split (':')[2]) as UserThemeSlotNo).tokens) + nextSavedThemeSlots = { ...nextSavedThemeSlots, [selection]: persisted } + } + + const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots) + + setSavedThemeSlots (completeThemeSlots) + setDraftThemeSlots (completeThemeSlots) + setCachedUserThemeSlots (completeThemeSlots) + applyClientAppearance (completeThemeSlots) + toast ({ title: 'テーマ設定を保存しました.' }) + } + catch (submitError) + { + applySettingsValidationError (submitError) + toast ({ title: 'テーマ設定を保存できませんでした.' }) + } + } + + const handleThemeDiscard = () => { + discardThemeChanges () + } + useEffect (() => { if (!user) return @@ -64,6 +1088,179 @@ const SettingPage: FC = ({ user, setUser }) => { setName (user.name ?? '') }, [user]) + useEffect (() => { + if (!(user)) + return + + let cancelled = false + + void (async () => { + setLoading (true) + + try + { + const [settings, themeSlots] = await Promise.all ([fetchUserSettings (), + fetchUserThemeSlots ()]) + if (cancelled) + return + + const storedThemeSlots = normaliseUserThemeSlots (themeSlots) + const completeThemeSlots = ensureCompleteThemeSlots (storedThemeSlots) + const themeMode = + hasStoredClientThemeSelection () + ? getClientThemeMode () + : settings.theme + const activeLightThemeSlotNo = getClientActiveLightThemeSlotNo () + const activeDarkThemeSlotNo = getClientActiveDarkThemeSlotNo () + + setSavedThemeMode (themeMode) + setDraftThemeMode (themeMode) + setSavedActiveLightThemeSlotNo (activeLightThemeSlotNo) + setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo) + setSavedActiveDarkThemeSlotNo (activeDarkThemeSlotNo) + setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo) + setSavedThemeSlots (completeThemeSlots) + setDraftThemeSlots (completeThemeSlots) + setCachedUserThemeSlots (completeThemeSlots) + applyThemeAppearanceState ({ themeMode, + activeLightThemeSlotNo, + activeDarkThemeSlotNo }, completeThemeSlots) + setSettingsError (null) + } + catch + { + if (cancelled) + return + + setSettingsError ('設定の読込みに失敗しました.') + } + finally + { + if (!(cancelled)) + setLoading (false) + } + }) () + + return () => { + cancelled = true + } + }, [user]) + + useEffect (() => { + if (userFieldErrors.name?.length) + applyActiveTab ('account') + }, [location.pathname, location.search, userFieldErrors.name]) + + useEffect (() => { + updateTabDirtyState ('account', { + dirty: hasUnsavedAccountChanges, + discard: discardAccountChanges }) + }, [hasUnsavedAccountChanges, savedName]) + + useEffect (() => { + if (settingsFieldErrors.theme?.length) + applyActiveTab ('theme') + }, [location.pathname, location.search, settingsFieldErrors.theme]) + + useEffect (() => { + applyThemeAppearanceState ({ + themeMode: draftThemeMode, + activeLightThemeSlotNo: draftActiveLightThemeSlotNo, + activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots) + + return () => { + setCachedUserThemeSlots (savedThemeSlots) + applyClientAppearance (savedThemeSlots) + } + }, [draftActiveDarkThemeSlotNo, + draftActiveLightThemeSlotNo, + draftThemeMode, + draftThemeSlots, + savedThemeSlots]) + + const accountSectionProps: AccountSectionProps | null = + user + ? { user, + name, + onNameChange: setName, + onInheritOpen: () => setInheritVsbl (true), + onUserCodeOpen: () => setUserCodeVsbl (true), + onUserSubmit: handleUserSubmit, + userBaseErrors, + userFieldErrors } + : null + + useEffect (() => { + updateTabDirtyState ('theme', { + dirty: hasUnsavedThemeChanges, + discard: handleThemeDiscard }) + }, [hasUnsavedThemeChanges, savedThemeSlots]) + + useEffect (() => { + registerUnsavedChangesSource ({ + dirty: hasPageUnsavedChanges, + discard: discardAllDirtyChanges }) + + return () => { + registerUnsavedChangesSource (null) + } + }, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource]) + + useKeyboardShortcuts ({ + ['settings.account']: () => { + requestActiveTab ('account') + }, + ['settings.behavior']: () => { + requestActiveTab ('behavior') + }, + ['settings.theme']: () => { + requestActiveTab ('theme') + }, + ['settings.keyboard']: () => { + requestActiveTab ('keyboard') + } }) + + const themeSectionProps: ThemeSectionProps = { + draftThemeMode, + draftActiveLightThemeSlotNo, + draftActiveDarkThemeSlotNo, + currentDisplayedThemeSlot: displayedThemeSelection.selection, + selectedTheme, + displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection), + systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト', + hasUnsavedChanges: hasUnsavedThemeChanges, + settingsBaseErrors, + settingsFieldErrors, + onSelectThemeMode: themeMode => { + requestThemeMode (themeMode) + }, + onSelectLightThemeSlotNo: slotNo => { + requestLightThemeSlotNo (slotNo) + }, + onSelectDarkThemeSlotNo: slotNo => { + requestDarkThemeSlotNo (slotNo) + }, + onThemeTokenChange: (key, value) => { + updateDraftThemeSlot (tokens => ({ ...tokens, [key]: value })) + }, + onTagColourChange: (category, colour) => { + updateDraftThemeSlot (tokens => ({ + ...tokens, tagColours: { ...tokens.tagColours, [category]: colour } })) + }, + onResetThemeSlot: () => { + const selection = displayedThemeSelection.selection + const baseTheme = + draftThemeSlots[selection]?.baseTheme + ?? selection.split (':')[1] as BuiltinThemeId + updateDraftThemeSlot ( + () => getDefaultUserThemeSlot ( + baseTheme, + (draftThemeSlots[selection]?.slotNo + ?? Number (selection.split (':')[2]) as UserThemeSlotNo)).tokens) + }, + onThemeSubmit: handleThemeSubmit, + onThemeDiscard: handleThemeDiscard } + return ( @@ -71,63 +1268,132 @@ const SettingPage: FC = ({ user, setUser }) => { 設定 | {SITE_TITLE} - +
設定 + +
+ {tabs.map (tab => ( + ))} +
+ {loading ? 'Loading...' : ( +
+ {accountSectionProps && activeTab === 'account' && ( + )} + {activeTab === 'theme' && } + {activeTab === 'keyboard' && ( + { + updateTabDirtyState ('keyboard', { dirty, discard }) + }}/>)} + {activeTab === 'behavior' && ( + { + updateTabDirtyState ('behavior', { dirty, discard }) + }}/>)} +
)} +
- {user ? ( - <> - +
+
+
+ {tabs.map (tab => ( + ))} +
- {/* 名前 */} - - {({ describedBy, invalid }) => ( - <> - setName (ev.target.value)}/> - {(user && !(user.name)) && ( -

- 名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!! -

)} - )} -
+
+ 設定 + + {loading ? 'Loading...' : ( +
+ {activeTab === 'account' && accountSectionProps && ( + )} + {activeTab === 'theme' && } + {activeTab === 'keyboard' && ( + { + updateTabDirtyState ('keyboard', { dirty, discard }) + }}/>)} + {activeTab === 'behavior' && ( + { + updateTabDirtyState ('behavior', { dirty, discard }) + }}/>)} +
)} +
+
+
- {/* 送信 */} - + - {/* 引継ぎ */} -
- - - -
- ) : 'Loading...'} - - - - - +
) } + export default SettingPage diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index dcbe12c..5b95db9 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -24,6 +24,11 @@ import type { FC } from 'react' import type { User, WikiPage } from '@/types' const mdParser = new MarkdownIt +const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const + +// `wiki_editor_mode` is still kept in backend settings, but the common +// settings screen does not expose it at present. Until that UI returns, the +// editor stays on the fixed split view here. type Props = { user: User | null } @@ -114,6 +119,7 @@ const WikiEditPage: FC = ({ user }) => { {() => ( mdParser.render (text)} onChange={({ text }) => setBody (text)}/>)} diff --git a/frontend/src/pages/wiki/WikiNewPage.tsx b/frontend/src/pages/wiki/WikiNewPage.tsx index fffb690..ac07b3d 100644 --- a/frontend/src/pages/wiki/WikiNewPage.tsx +++ b/frontend/src/pages/wiki/WikiNewPage.tsx @@ -22,6 +22,11 @@ import 'react-markdown-editor-lite/lib/index.css' import type { User, WikiPage } from '@/types' const mdParser = new MarkdownIt +const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const + +// `wiki_editor_mode` is still kept in backend settings, but the common +// settings screen does not expose it at present. Until that UI returns, the +// editor stays on the fixed split view here. type Props = { user: User | null } @@ -92,6 +97,7 @@ const WikiNewPage: FC = ({ user }) => { {() => ( mdParser.render (text)} onChange={({ text }) => setBody (text)}/>)} diff --git a/frontend/src/test/render.tsx b/frontend/src/test/render.tsx index c768200..ce8a1d9 100644 --- a/frontend/src/test/render.tsx +++ b/frontend/src/test/render.tsx @@ -3,6 +3,10 @@ import { render } from '@testing-library/react' import { HelmetProvider } from 'react-helmet-async' import { MemoryRouter } from 'react-router-dom' +import DialogueProvider from '@/components/dialogues/DialogueProvider' +import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' +import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' + import type { ReactElement, ReactNode } from 'react' type Options = { @@ -15,21 +19,25 @@ export const renderWithProviders = ( ) => { const queryClient = new QueryClient ({ defaultOptions: { - queries: { retry: false }, - }, + queries: { retry: false } }, }) const Wrapper = ({ children }: { children: ReactNode }) => ( - {children} + + + + {children} + + + ) return { queryClient, - ...render (ui, { wrapper: Wrapper }), - } + ...render (ui, { wrapper: Wrapper }) } } diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 12f1e30..ef2725c 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -20,3 +20,10 @@ window.requestAnimationFrame = callback => { callback (0) return 0 } + +Object.defineProperty (window, 'matchMedia', { + configurable: true, + value: vi.fn ().mockImplementation (() => ({ + addEventListener: vi.fn (), + removeEventListener: vi.fn (), + matches: false })) }) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 16795ac..3ffb5b0 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -8,6 +8,7 @@ import { DARK_COLOUR_SHADE, const colours = Object.values (TAG_COLOUR) export default { + darkMode: 'class', content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'], safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`), ...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),