コミットを比較

...

12 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 3898fbc156 #34 2026-07-07 00:38:18 +09:00
みてるぞ 6d8cab9822 #34 2026-07-07 00:07:12 +09:00
みてるぞ f2651a987a #34 2026-07-06 23:37:29 +09:00
みてるぞ 4478bc92ed #34 2026-07-06 23:25:14 +09:00
みてるぞ 5f5299ea39 #34 2026-07-06 23:18:52 +09:00
みてるぞ 2d9a79fecc #34 2026-07-06 23:08:27 +09:00
みてるぞ e74f16782b #34 2026-07-06 22:39:53 +09:00
みてるぞ 8ac56f74ce #34 2026-07-06 22:02:25 +09:00
みてるぞ 59a87c0e11 #34 2026-07-06 21:17:39 +09:00
みてるぞ f66532a55d #34 2026-07-06 21:12:49 +09:00
みてるぞ c3d2d8e1d1 #34 2026-07-06 20:46:16 +09:00
みてるぞ f4dea9b91e #34 2026-07-06 19:34:11 +09:00
44個のファイルの変更2208行の追加1181行の削除
+214
ファイルの表示
@@ -362,6 +362,37 @@ const value =
- Do not use a leading semicolon for expression statements such as - Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`. 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, - Use correct British English spelling for new identifiers, filenames,
component names, helper names, comments, and developer-facing prose unless component names, helper names, comments, and developer-facing prose unless
editing an already established American-English API that must keep its editing an already established American-English API that must keep its
@@ -380,6 +411,168 @@ const value =
- For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not - For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not
`BehaviorSettingsSection.tsx`. `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 <div/>
}
export default Component
```
Good:
```ts
const Component: FC = () => {
return <div/>
}
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 ### Frontend delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is Use this table before accepting any edited TypeScript or TSX hunk. The table is
@@ -412,6 +605,7 @@ import { Button, Card } from '@/components/ui'
Rule: named-binding `}` is associative-array-style syntax. It must not be alone 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 `}` at the beginning of a line. Prefer keeping `{` with the first binding and `}`
with the final binding when this fits the line limit. with the final binding when this fits the line limit.
Penalty: capital offence. This is one of the core associative-brace rules.
#### Type literals #### Type literals
@@ -434,6 +628,8 @@ type Props = {
Rule: type-literal `}` is not a block close. It must stay on the same line as 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. 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 #### Object literals
@@ -474,6 +670,8 @@ const value = useMemo (() => ({
Rule: object-literal `}` is associative-array-style syntax. It must not be on a 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 line by itself. Keep it with the final property, and keep call `)` off the
beginning of a line. beginning of a line.
Penalty: capital offence. Wrong object-literal `}` placement and wrong call
`)` placement are both death-class violations here.
#### Destructuring parameters #### Destructuring parameters
@@ -504,6 +702,8 @@ that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>`
shape. Put the function parameter list on its own lines. The destructuring `}` shape. Put the function parameter list on its own lines. The destructuring `}`
stays with the final binding. The parameter-list `)` is then allowed and stays with the final binding. The parameter-list `)` is then allowed and
required at the beginning of its own line. 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 #### Inline typed destructuring parameter
@@ -531,6 +731,8 @@ const RouteTransitionWrapper = ({ user, setUser }: {
Rule: this is not a separately split parameter-list block. The line break is 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 inside the inline type. Keep the type-literal `}` and parameter-list `)` on the
same line as the final type property. 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 #### Multi-line normal parameter list
@@ -558,6 +760,8 @@ const updateDraft = <Key extends keyof Settings,> (
Rule: when the parameter list itself is split across multiple parameter lines, 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 closing parameter `)` goes at the beginning of its own line before `=>` or
the return type. 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 #### Function and callback blocks
@@ -586,6 +790,8 @@ const handleSave = () => {
Rule: block `}` closes executable code, not associative data. Multi-line Rule: block `}` closes executable code, not associative data. Multi-line
function, lambda, callback, `if`, `for`, `switch`, and similar block braces function, lambda, callback, `if`, `for`, `switch`, and similar block braces
belong on their own line. belong on their own line.
Penalty: capital offence. Confusing block `}` with associative `}` is a
death-class delimiter error.
#### Function and method calls #### Function and method calls
@@ -609,6 +815,8 @@ const value = compute (
Rule: call-expression `)` must not be at the beginning of a line. The exception 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 for leading `)` applies only to function declaration parameter lists, never to
calls. calls.
Penalty: capital offence. A leading call `)` is one of the clearest death-class
violations in this codebase.
#### JSX closing markers #### JSX closing markers
@@ -653,6 +861,8 @@ Good:
Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in
the local compact form such as `</div>)`. the local compact form such as `</div>)`.
Penalty: medium-severity offence. It is usually not semantically broken, but it
is a conspicuous TSX-style failure.
#### Arrays and tuples #### Arrays and tuples
@@ -681,6 +891,8 @@ const items = [first, second]
Rule: array and tuple `]` must not be at the beginning of a line. Keep it with 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. 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 #### Single-line braces
@@ -705,6 +917,8 @@ Good:
Rule: JavaScript object braces on one line get one inner space. JSX expression Rule: JavaScript object braces on one line get one inner space. JSX expression
braces do not get inner spaces. braces do not get inner spaces.
Penalty: minor offence. This is not normally semantic breakage, but it still
counts as a style miss.
#### Final TypeScript/TSX self-review checklist #### Final TypeScript/TSX self-review checklist
生成ファイル
+33 -12
ファイルの表示
@@ -72,6 +72,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id" t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id" t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
t.index ["user_id"], name: "index_gekanator_games_on_user_id" t.index ["user_id"], name: "index_gekanator_games_on_user_id"
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
end end
create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -274,9 +275,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at" t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at" t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -285,13 +287,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id" t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end end
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "target_post_id", null: false t.bigint "target_post_id", null: false
t.float "cos", null: false t.float "cos", null: false
t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos" t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc }
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end end
@@ -347,6 +350,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["post_id"], name: "index_post_versions_on_post_id"
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id" t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -364,6 +369,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["url"], name: "index_posts_on_url", unique: true t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -371,18 +377,25 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "theme", default: "system", null: false t.string "theme", default: "system", null: false
t.string "display_density", default: "comfortable", null: false
t.string "font_size", default: "normal", null: false
t.integer "post_list_limit", default: 50, null: false
t.string "post_list_order", default: "created_at_desc", null: false
t.string "viewed_post_display", default: "show", null: false
t.boolean "tag_autocomplete_nico", default: true, null: false
t.string "auto_fetch_title", default: "manual", null: false t.string "auto_fetch_title", default: "manual", null: false
t.string "auto_fetch_thumbnail", default: "manual", null: false t.string "auto_fetch_thumbnail", default: "manual", null: false
t.string "wiki_editor_mode", default: "split", null: false t.string "wiki_editor_mode", default: "split", null: false
t.index ["user_id"], name: "index_settings_on_user_id", unique: true t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end 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| create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false t.bigint "parent_tag_id", null: false
@@ -418,7 +431,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "target_tag_id", null: false t.bigint "target_tag_id", null: false
t.float "cos", null: false t.float "cos", null: false
t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos" t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc }
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
end end
@@ -434,9 +447,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at" t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at" t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at" t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
end end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -452,6 +466,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end end
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -605,11 +620,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -654,6 +671,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id" t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
@@ -735,6 +754,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users" add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "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" add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id"
+34
ファイルの表示
@@ -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
+40
ファイルの表示
@@ -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
+98
ファイルの表示
@@ -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
+104
ファイルの表示
@@ -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
+63 -10
ファイルの表示
@@ -1,5 +1,5 @@
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react' import { Fragment, useEffect, useMemo, useState } from 'react'
import { BrowserRouter, import { BrowserRouter,
Navigate, Navigate,
Route, Route,
@@ -12,7 +12,9 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider' import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { applyClientAppearance, import { applyClientAnimationMode,
applyClientAppearance,
type ClientAnimationMode,
fetchUserThemeSlots, fetchUserThemeSlots,
fetchUserSettings, fetchUserSettings,
getClientThemeMode, getClientThemeMode,
@@ -21,6 +23,7 @@ import { applyClientAppearance,
setCachedUserThemeSlots, setCachedUserThemeSlots,
seedClientThemeMode } from '@/lib/settings' seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
@@ -59,11 +62,51 @@ import type { Dispatch, FC, SetStateAction } from 'react'
import type { User } from '@/types' import type { User } from '@/types'
const RouteTransitionWrapper = ({ user, setUser }: { const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode
user: User | null user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => { setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation () const location = useLocation ()
if (animationMode === 'off')
{
return (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
}
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<Routes location={location}> <Routes location={location}>
@@ -116,6 +159,7 @@ const App: FC = () => {
const [status, setStatus] = useState (200) const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
const appLayoutTransition = useMemo ( const appLayoutTransition = useMemo (
() => ( () => (
@@ -141,6 +185,10 @@ const App: FC = () => {
return () => mediaQuery.removeEventListener ('change', handleThemeChange) return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, []) }, [])
useEffect (() => {
applyClientAnimationMode (animationMode)
}, [animationMode])
useEffect (() => { useEffect (() => {
const createUser = async () => { const createUser = async () => {
const data = await apiPost<{ code: string; user: User }> ('/users') const data = await apiPost<{ code: string; user: User }> ('/users')
@@ -210,8 +258,9 @@ const App: FC = () => {
{import.meta.env.DEV && <DevModeWatermark/>} {import.meta.env.DEV && <DevModeWatermark/>}
<BrowserRouter> <BrowserRouter>
<KeyboardShortcutsProvider> <DialogueProvider>
<DialogueProvider> <UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig <MotionConfig
reducedMotion={ reducedMotion={
animationMode === 'normal' animationMode === 'normal'
@@ -220,20 +269,24 @@ const App: FC = () => {
? 'user' ? 'user'
: 'always' : 'always'
}> }>
<LayoutGroup> <LayoutWrapper>
<motion.div <motion.div
layout={animationMode === 'off' ? false : 'position'} layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }} transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden"> className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/> <TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/> <RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div> </motion.div>
</LayoutGroup> </LayoutWrapper>
</MotionConfig> </MotionConfig>
<Toaster/> <Toaster/>
</DialogueProvider> </KeyboardShortcutsProvider>
</KeyboardShortcutsProvider> </UnsavedChangesGuardProvider>
</DialogueProvider>
</BrowserRouter> </BrowserRouter>
</>) </>)
} }
+12 -2
ファイルの表示
@@ -4,6 +4,8 @@ import { motion } from 'framer-motion'
import { useRef } from 'react' import { useRef } from 'react'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MutableRefObject } from 'react' import type { CSSProperties, FC, MutableRefObject } from 'react'
@@ -20,6 +22,12 @@ type Props = {
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { const DraggableDroppableTagRow: FC<Props> = ({ 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 dndId = `tag-node:${ pathKey }`
const downPosRef = useRef<{ x: number; y: number } | null> (null) const downPosRef = useRef<{ x: number; y: number } | null> (null)
@@ -94,8 +102,10 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
{...listeners}> {...listeners}>
<motion.div <motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden" className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: layoutTransition }}
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}> layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/> <TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div> </motion.div>
</div>) </div>)
+20 -4
ファイルの表示
@@ -6,6 +6,7 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -35,11 +36,19 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
[to],
)
const url = useMemo (() => { const url = useMemo (() => {
const path = (typeof to === 'string') ? to : createPath (to) return (new URL (path, window.location.origin)).toString ()
return (new URL (path, location.origin)).toString () }, [path])
}, [to]) const nextPathname = useMemo (
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive) const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => { const doPrefetch = async () => {
@@ -84,6 +93,13 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault () ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => { flushSync (() => {
setOverlay (true) setOverlay (true)
}) })
@@ -106,7 +122,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
return ( return (
<a ref={ref} <a ref={ref}
href={typeof to === 'string' ? to : createPath (to)} href={path}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onClick={handleClick} onClick={handleClick}
+5 -1
ファイルの表示
@@ -1,3 +1,4 @@
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@@ -20,6 +21,8 @@ const MIN_MARQUEE_OVERFLOW_PX = 1
const ResponsiveMarqueeText: FC<Props> = ( const ResponsiveMarqueeText: FC<Props> = (
{ text, className, truncateOnMobile = false, title }, { text, className, truncateOnMobile = false, title },
) => { ) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const outerRef = useRef<HTMLSpanElement | null> (null) const outerRef = useRef<HTMLSpanElement | null> (null)
const staticRef = useRef<HTMLSpanElement | null> (null) const staticRef = useRef<HTMLSpanElement | null> (null)
const animatedRef = useRef<HTMLSpanElement | null> (null) const animatedRef = useRef<HTMLSpanElement | null> (null)
@@ -98,6 +101,7 @@ const ResponsiveMarqueeText: FC<Props> = (
const animated = animatedRef.current const animated = animatedRef.current
const canMarquee = ( const canMarquee = (
active active
&& animationMode !== 'off'
&& desktopMarqueeEnabled && desktopMarqueeEnabled
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX && overflowPx >= MIN_MARQUEE_OVERFLOW_PX
&& animated != null) && animated != null)
@@ -207,7 +211,7 @@ const ResponsiveMarqueeText: FC<Props> = (
resetAnimated () resetAnimated ()
setMarqueeVisible (false) setMarqueeVisible (false)
} }
}, [active, desktopMarqueeEnabled, overflowPx, text]) }, [active, animationMode, desktopMarqueeEnabled, overflowPx, text])
return ( return (
<span <span
+90 -67
ファイルの表示
@@ -20,6 +20,7 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils' import { dateString, originalCreatedAtString } from '@/lib/utils'
@@ -30,9 +31,6 @@ import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, TagWithSections } from '@/types' import type { Category, Post, TagWithSections } from '@/types'
type TagByCategory = { [key in Category]: TagWithSections[] } type TagByCategory = { [key in Category]: TagWithSections[] }
type FlatTagRow = {
tag: TagWithSections
parentTagId?: number }
const renderTagTree = ( const renderTagTree = (
@@ -64,34 +62,6 @@ const renderTagTree = (
?? [])] ?? [])]
} }
const flattenTagTree = (
tags: TagWithSections[],
): FlatTagRow[] => {
const seen = new Set<number> ()
const rows: FlatTagRow[] = []
const visit = (
tag: TagWithSections,
parentTagId?: number,
) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
rows.push ({ tag, parentTagId })
for (const child of tag.children ?? [])
visit (child, tag.id)
}
for (const tag of tags)
visit (tag)
return rows.sort ((rowA, rowB) => rowA.tag.name < rowB.tag.name ? -1 : 1)
}
const isDescendant = ( const isDescendant = (
root: TagWithSections, root: TagWithSections,
targetId: number, targetId: number,
@@ -158,6 +128,37 @@ const buildTagByCategory = (post: Post): TagByCategory => {
} }
const buildFlatTagByCategory = (
byCategory: TagByCategory,
): TagByCategory => {
const tagsTmp = { } as TagByCategory
const seen = new Set<number> ()
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 ( const changeCategory = async (
tagId: number, tagId: number,
category: Category): Promise<void> => { category: Category): Promise<void> => {
@@ -189,6 +190,11 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped' const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped'
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const baseTags = useMemo<TagByCategory> (() => { const baseTags = useMemo<TagByCategory> (() => {
const tagsTmp = { } as TagByCategory const tagsTmp = { } as TagByCategory
@@ -210,6 +216,10 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
const [dragging, setDragging] = useState (false) const [dragging, setDragging] = useState (false)
const [saving, setSaving] = useState (false) const [saving, setSaving] = useState (false)
const [tags, setTags] = useState (baseTags) const [tags, setTags] = useState (baseTags)
const flatTagsByCategory = useMemo<TagByCategory> (
() => buildFlatTagByCategory (tags),
[tags],
)
const suppressClickRef = useRef (false) const suppressClickRef = useRef (false)
@@ -343,45 +353,58 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
document.body.style.userSelect = '' document.body.style.userSelect = ''
}} }}
modifiers={[restrictToWindowEdges]}> modifiers={[restrictToWindowEdges]}>
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( {CATEGORIES.map ((cat: Category) => {
<div className="my-3" key={cat}> const categoryTags =
<SubsectionTitle> tagRelationDisplay === 'grouped'
<motion.div ? (tags[cat] ?? [])
layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`} : (flatTagsByCategory[cat] ?? [])
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
{CATEGORY_NAMES[cat]}
</motion.div>
</SubsectionTitle>
<ul> if (!(categoryTags.length > 0 || dragging))
{(tagRelationDisplay === 'grouped' return null
? (tags[cat] ?? []).flatMap (tag =>
renderTagTree ( return (
tag, <div className="my-3" key={cat}>
0, <SubsectionTitle>
`cat-${ cat }`, <motion.div
suppressClickRef, layoutId={animationMode === 'off'
undefined, ? undefined
sp, : `tag-${ sp ? 'sp-' : '' }${ cat }`}
), transition={{ layout: layoutTransition }}>
) {CATEGORY_NAMES[cat]}
: flattenTagTree (tags[cat] ?? []).map (row => ( </motion.div>
<li key={`flat-${ cat }-${ row.tag.id }`} className="mb-1"> </SubsectionTitle>
<DraggableDroppableTagRow
tag={row.tag} <ul>
nestLevel={0} {(tagRelationDisplay === 'grouped'
pathKey={`flat-${ cat }-${ row.tag.id }`} ? (tags[cat] ?? []).flatMap (tag =>
parentTagId={row.parentTagId} renderTagTree (
suppressClickRef={suppressClickRef} tag,
sp={sp}/> 0,
</li>)))} `cat-${ cat }`,
<DropSlot cat={cat}/> suppressClickRef,
</ul> undefined,
</div>))} sp,
),
)
: (flatTagsByCategory[cat] ?? []).map (tag => (
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
<DraggableDroppableTagRow
tag={tag}
nestLevel={0}
pathKey={`flat-${ cat }-${ tag.id }`}
suppressClickRef={suppressClickRef}
sp={sp}/>
</li>)))}
<DropSlot cat={cat}/>
</ul>
</div>)
})}
{post && ( {post && (
<motion.div <motion.div
layoutId={`post-info-${ sp }`} layoutId={animationMode === 'off'
transition={{ layout: { duration: .2, ease: 'easeOut' } }}> ? undefined
: `post-info-${ sp }`}
transition={{ layout: layoutTransition }}>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
<ul> <ul>
<li>Id.: {post.id}</li> <li>Id.: {post.id}</li>
+40 -18
ファイルの表示
@@ -8,6 +8,8 @@ import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
@@ -21,6 +23,13 @@ type Props = { posts: Post[]
const TagSidebar: FC<Props> = ({ posts, onClick }) => { const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const navigate = useNavigate () 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 [tagsVsbl, setTagsVsbl] = useState (false)
const [tags, setTags] = useState<TagByCategory> ({ }) const [tags, setTags] = useState<TagByCategory> ({ })
@@ -65,12 +74,18 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
{CATEGORIES.flatMap (cat => cat in tags ? ( {CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => ( tags[cat].map (tag => (
<li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden"> <li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden">
<motion.div {animationsOff
className="flex min-w-0 max-w-full items-baseline overflow-hidden" ? (
transition={{ layout: { duration: .2, ease: 'easeOut' } }} <div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
layoutId={`tag-${ tag.id }`}> <TagLink tag={tag} onClick={onClick}/>
<TagLink tag={tag} onClick={onClick}/> </div>)
</motion.div> : (
<motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
transition={{ layout: layoutTransition }}
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div>)}
</li>))) : [])} </li>))) : [])}
</ul> </ul>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
@@ -103,18 +118,25 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
{posts.length > 0 && TagBlock} {posts.length > 0 && TagBlock}
</div> </div>
<AnimatePresence initial={false}> {animationsOff
{tagsVsbl && ( ? (
<motion.div tagsVsbl && (
key="sptags" <div className="md:hidden overflow-hidden">
className="md:hidden overflow-hidden" {posts.length > 0 && TagBlock}
initial={{ height: 0 }} </div>))
animate={{ height: 'auto' }} : (
exit={{ height: 0 }} <AnimatePresence initial={false}>
transition={{ duration: .2, ease: 'easeOut' }}> {tagsVsbl && (
{posts.length > 0 && TagBlock} <motion.div
</motion.div>)} key="sptags"
</AnimatePresence> className="md:hidden overflow-hidden"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: 0 }}
transition={layoutTransition}>
{posts.length > 0 && TagBlock}
</motion.div>)}
</AnimatePresence>)}
<a href="#" <a href="#"
className="md:hidden block my-2 text-center text-sm className="md:hidden block my-2 text-center text-sm
+10 -6
ファイルの表示
@@ -161,13 +161,17 @@ const TopNav: FC<Props> = ({ user }) => {
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to)) visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40) const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
const topNavTransition = const topNavTransition =
reducedAnimations animationsOff
? { duration: .08, ease: 'linear' as const } ? { duration: 0 }
: { duration: .2, ease: 'easeOut' as const } : reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const opacityTransition = const opacityTransition =
reducedAnimations animationsOff
? { duration: .08 } ? { duration: 0 }
: { duration: .12 } : reducedAnimations
? { duration: .08 }
: { duration: .12 }
const highlightTransitionClass = const highlightTransitionClass =
animationsOff animationsOff
? undefined ? undefined
+12
ファイルの表示
@@ -20,4 +20,16 @@ describe ('TabGroup', () => {
expect (screen.getByText ('Alpha')).toBeInTheDocument () expect (screen.getByText ('Alpha')).toBeInTheDocument ()
expect (screen.queryByText ('Beta')).not.toBeInTheDocument () expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
}) })
it ('renders tabs as buttons and shows the right addon', () => {
render (
<TabGroup rightAddon={<span> 20</span>}>
<Tab name="広場">Plaza</Tab>
</TabGroup>,
)
expect (screen.getByRole ('button', { name: '広場' })).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '広場' })).not.toBeInTheDocument ()
expect (screen.getByText ('件数 20')).toBeInTheDocument ()
})
}) })
+18 -7
ファイルの表示
@@ -1,5 +1,7 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
@@ -9,12 +11,21 @@ type Props = {
className?: string } className?: string }
const MainArea: FC<Props> = ({ children, className }) => ( const MainArea: FC<Props> = ({ children, className }) => {
<motion.main const behaviourSettings = useClientBehaviourSettings ()
transition={{ layout: { duration: .2, ease: 'easeOut' } }} const animationMode = behaviourSettings.animation ?? 'normal'
className={cn ('flex-1 overflow-y-auto p-4', className)} const layoutTransition = clientAnimationTransition (
layout="position"> animationMode,
{children} { normal: { duration: .2, ease: 'easeOut' as const } },
</motion.main>) )
return (
<motion.main
transition={{ layout: layoutTransition }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout={animationMode === 'off' ? false : 'position'}>
{children}
</motion.main>)
}
export default MainArea export default MainArea
+10 -2
ファイルの表示
@@ -1,6 +1,8 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react' import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
@@ -68,6 +70,12 @@ const SidebarComponent: FC<Props> = ({
sidebarKey, sidebarKey,
side = 'left', side = 'left',
}) => { }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const rootRef = useRef<HTMLDivElement | null> (null) const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth) const maxWidthRef = useRef (maxWidth)
const getContainerWidth = useCallback (() => { const getContainerWidth = useCallback (() => {
@@ -305,8 +313,8 @@ const SidebarComponent: FC<Props> = ({
return ( return (
<motion.div <motion.div
ref={rootRef} ref={rootRef}
layout="position" layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: layoutTransition }}
style={style} style={style}
className={cn ( className={cn (
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full', 'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
+11 -37
ファイルの表示
@@ -11,8 +11,7 @@ import type { ClientAnimationMode,
ClientBehaviorSettings, ClientBehaviorSettings,
ClientEmbedAutoLoadMode, ClientEmbedAutoLoadMode,
ClientLinkPreloadMode, ClientLinkPreloadMode,
ClientTagRelationDisplayMode, ClientTagRelationDisplayMode } from '@/lib/settings'
ClientThumbnailMode } from '@/lib/settings'
type Props = { type Props = {
sectionClassName: string sectionClassName: string
@@ -31,9 +30,9 @@ type SegmentedControlProps<T extends string> = {
onChange: (value: T) => void } onChange: (value: T) => void }
type SettingBlockProps = { type SettingBlockProps = {
title: string title: string
description: string description?: string
children: ReactNode } children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [ const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
{ value: 'off', label: 'なし' }, { value: 'off', label: 'なし' },
@@ -47,18 +46,12 @@ const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [ const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
{ value: 'off', label: 'しない' }, { value: 'off', label: 'しない' },
{ value: 'intent', label: 'マウスを置いたら' }] { value: 'intent', label: 'する' }]
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [ const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
{ value: 'flat', label: 'まとめない' }, { value: 'flat', label: 'まとめない' },
{ value: 'grouped', label: 'まとめる' }] { value: 'grouped', label: 'まとめる' }]
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
{ value: 'off', label: '非表示' },
{ value: 'light', label: '軽量' },
{ value: 'normal', label: '通常' }]
const SegmentedControl = <T extends string,> ( const SegmentedControl = <T extends string,> (
{ value, options, onChange }: SegmentedControlProps<T>, { value, options, onChange }: SegmentedControlProps<T>,
) => { ) => {
@@ -90,7 +83,7 @@ const SegmentedControl = <T extends string,> (
const SettingBlock: FC<SettingBlockProps> = ( const SettingBlock: FC<SettingBlockProps> = (
{ title, description, children }, { title, description, children },
) => ( ) => (
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4"> <div className="space-y-3 rounded-xl px-4">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3> <h3 className="text-sm font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p> <p className="text-sm text-muted-foreground">{description}</p>
@@ -149,16 +142,12 @@ const BehaviourSettingsSection: FC<Props> = (
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-bold"></h2> <h2 className="text-xl font-bold"></h2>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="destructive"
disabled={hasUnsavedChanges === false} disabled={hasUnsavedChanges === false}
onClick={handleDiscard}> onClick={handleDiscard}>
@@ -173,9 +162,7 @@ const BehaviourSettingsSection: FC<Props> = (
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingBlock <SettingBlock title="アニメーション">
title="アニメーション"
description="画面遷移やレイアウト移動の動きを調整します。">
<SegmentedControl <SegmentedControl
value={draftSettings.animation ?? 'normal'} value={draftSettings.animation ?? 'normal'}
options={animationOptions} options={animationOptions}
@@ -184,39 +171,26 @@ const BehaviourSettingsSection: FC<Props> = (
<SettingBlock <SettingBlock
title="リンク先の先読み" title="リンク先の先読み"
description="リンクにマウスを置いた時などに、移動先の情報を先に読みます。通信量や意図しない読込みが気になる場合は「しない」にできます。"> description="マウスを置いた時点でリンク先のデータを取得します。">
<SegmentedControl <SegmentedControl
value={draftSettings.linkPreload ?? 'intent'} value={draftSettings.linkPreload ?? 'intent'}
options={linkPreloadOptions} options={linkPreloadOptions}
onChange={value => updateDraft ('linkPreload', value)}/> onChange={value => updateDraft ('linkPreload', value)}/>
</SettingBlock> </SettingBlock>
<SettingBlock <SettingBlock title="タグの親子関係表示">
title="タグの親子関係表示"
description="親子関係があるタグを、まとまりとして表示します。見た目を単純にしたい場合は「まとめない」にできます。">
<SegmentedControl <SegmentedControl
value={draftSettings.tagRelationDisplay ?? 'grouped'} value={draftSettings.tagRelationDisplay ?? 'grouped'}
options={tagRelationDisplayOptions} options={tagRelationDisplayOptions}
onChange={value => updateDraft ('tagRelationDisplay', value)}/> onChange={value => updateDraft ('tagRelationDisplay', value)}/>
</SettingBlock> </SettingBlock>
<SettingBlock <SettingBlock title="埋め込み自動読込">
title="埋め込み自動読込"
description="外部埋め込みを自動で読み込むかを切り替えます。">
<SegmentedControl <SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'} value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions} options={embedAutoLoadOptions}
onChange={value => updateDraft ('embedAutoLoad', value)}/> onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock> </SettingBlock>
<SettingBlock
title="サムネイル表示"
description="投稿一覧や詳細の画像表示を軽量化します。">
<SegmentedControl
value={draftSettings.thumbnailMode ?? 'normal'}
options={thumbnailModeOptions}
onChange={value => updateDraft ('thumbnailMode', value)}/>
</SettingBlock>
</div> </div>
</section>) </section>)
} }
+3 -7
ファイルの表示
@@ -147,10 +147,6 @@ const KeyboardSettingsSection: FC<Props> = (
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-bold"></h2> <h2 className="text-xl font-bold"></h2>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -167,7 +163,7 @@ const KeyboardSettingsSection: FC<Props> = (
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="destructive"
disabled={!(hasUnsavedChanges)} disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}> onClick={handleDiscard}>
@@ -206,8 +202,8 @@ const KeyboardSettingsSection: FC<Props> = (
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
</tr> </tr>
</thead> </thead>
-97
ファイルの表示
@@ -1,97 +0,0 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import {
applyClientAppearance,
DEFAULT_USER_SETTINGS,
fetchUserSettings,
} from '@/lib/settings'
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { User } from '@/types'
import type { UserSettings } from '@/lib/settings'
type ContextValue = {
loaded: boolean
error: string | null
settings: UserSettings
setSettings: Dispatch<SetStateAction<UserSettings>> }
const UserSettingsContext = createContext<ContextValue | null> (null)
export const UserSettingsProvider: FC<{
children: ReactNode
user: User | null }> = ({ children, user }) => {
const [error, setError] = useState<string | null> (null)
const [loaded, setLoaded] = useState (false)
const [settings, setSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
useEffect (() => {
let cancelled = false
if (!(user))
{
setError (null)
setSettings (DEFAULT_USER_SETTINGS)
setLoaded (true)
return
}
setLoaded (false)
setError (null)
void (async () => {
try
{
const next = await fetchUserSettings ()
if (!(cancelled))
{
setError (null)
setSettings (next)
setLoaded (true)
}
}
catch
{
if (!(cancelled))
{
setError ('設定を読み込めませんでした.既定値で表示しています.')
setSettings (DEFAULT_USER_SETTINGS)
setLoaded (true)
}
}
}) ()
return () => {
cancelled = true
}
}, [user])
useEffect (() => {
void settings.theme
applyClientAppearance ()
}, [settings.theme])
const value = useMemo<ContextValue> (() => ({
error,
loaded,
settings,
setSettings,
}), [error, loaded, settings])
return (
<UserSettingsContext.Provider value={value}>
{children}
</UserSettingsContext.Provider>)
}
export const useUserSettings = (): ContextValue => {
const value = useContext (UserSettingsContext)
if (value == null)
throw new Error ('UserSettingsProvider is missing')
return value
}
+8 -27
ファイルの表示
@@ -38,6 +38,7 @@
--top-nav-root-bg-desktop: #fefce8; --top-nav-root-bg-desktop: #fefce8;
--top-nav-active-bg: #fef08a; --top-nav-active-bg: #fef08a;
--top-nav-submenu-bg: #fef08a; --top-nav-submenu-bg: #fef08a;
--top-nav-mobile-menu-bg: #fef08a;
--top-nav-mobile-active-bg: #fefce8; --top-nav-mobile-active-bg: #fefce8;
--top-nav-brand-link: #db2777; --top-nav-brand-link: #db2777;
--top-nav-menu-link: #1d4ed8; --top-nav-menu-link: #1d4ed8;
@@ -89,6 +90,7 @@
--top-nav-root-bg-desktop: #230505; --top-nav-root-bg-desktop: #230505;
--top-nav-active-bg: #450a0a; --top-nav-active-bg: #450a0a;
--top-nav-submenu-bg: #450a0a; --top-nav-submenu-bg: #450a0a;
--top-nav-mobile-menu-bg: #230505;
--top-nav-mobile-active-bg: #450a0a; --top-nav-mobile-active-bg: #450a0a;
--top-nav-brand-link: #f9a8d4; --top-nav-brand-link: #f9a8d4;
--top-nav-menu-link: #93c5fd; --top-nav-menu-link: #93c5fd;
@@ -105,7 +107,7 @@
} }
a:hover a:hover
{ {
opacity: .85; color: color-mix(in oklab, hsl(var(--theme-link)), white 18%);
} }
} }
@@ -125,29 +127,6 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
:root[data-font-size='small']
{
font-size: 14px;
}
:root[data-font-size='normal']
{
font-size: 16px;
}
:root[data-font-size='large']
{
font-size: 18px;
}
:root[data-display-density='compact'] input,
:root[data-display-density='compact'] textarea,
:root[data-display-density='compact'] select,
:root[data-display-density='compact'] button
{
line-height: 1.25;
}
:root[data-animation='reduced'] *, :root[data-animation='reduced'] *,
:root[data-animation='off'] * :root[data-animation='off'] *
{ {
@@ -158,9 +137,11 @@
:root[data-animation='off'] *::before, :root[data-animation='off'] *::before,
:root[data-animation='off'] *::after :root[data-animation='off'] *::after
{ {
animation-duration: 0.001ms !important; animation-duration: 0ms !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important; transition-duration: 0ms !important;
transition-delay: 0ms !important;
} }
a a
@@ -260,7 +241,7 @@ body
.top-nav-mobile-menu .top-nav-mobile-menu
{ {
background: var(--top-nav-root-bg-mobile); background: var(--top-nav-mobile-menu-bg);
} }
.top-nav-mobile-active .top-nav-mobile-active
+34
ファイルの表示
@@ -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'
+65
ファイルの表示
@@ -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)
})
})
+108
ファイルの表示
@@ -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<FetchPostsOrder> ('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<FetchPostsOrder> ('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%)')
})
})
+436 -419
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
+37 -11
ファイルの表示
@@ -25,6 +25,7 @@ import {
getEffectiveKeyBindings, getEffectiveKeyBindings,
setClientKeyboardSettings, setClientKeyboardSettings,
} from '@/lib/settings' } from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type { import type {
KeyBinding, KeyBinding,
@@ -96,6 +97,7 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] = const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ()) useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
@@ -146,6 +148,13 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
} }
}, []) }, [])
const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
const builtinHandlers = useMemo<ShortcutHandlers> ( const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({ () => ({
'global.openShortcutHelp': () => { 'global.openShortcutHelp': () => {
@@ -154,16 +163,16 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
'global.focusSearch': () => { 'global.focusSearch': () => {
focusSearchTarget () focusSearchTarget ()
}, },
'navigation.posts': () => navigate ('/posts'), 'navigation.posts': () => guardedNavigate ('/posts'),
'navigation.tags': () => navigate ('/tags'), 'navigation.tags': () => guardedNavigate ('/tags'),
'navigation.materials': () => navigate ('/materials'), 'navigation.materials': () => guardedNavigate ('/materials'),
'navigation.wiki': () => navigate ('/wiki'), 'navigation.wiki': () => guardedNavigate ('/wiki'),
'settings.account': () => navigate ('/users/settings?tab=account'), 'settings.account': () => guardedNavigate ('/users/settings?tab=account'),
'settings.theme': () => navigate ('/users/settings?tab=theme'), 'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'),
'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'), 'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'),
'settings.behavior': () => navigate ('/users/settings?tab=behavior'), 'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'),
}), }),
[navigate], [guardedNavigate],
) )
const availableActionIds = useMemo<Set<ShortcutActionId>> ( const availableActionIds = useMemo<Set<ShortcutActionId>> (
@@ -282,10 +291,27 @@ export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue =>
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => { export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
const { registerHandlers } = useKeyboardShortcutSettings () const { registerHandlers } = useKeyboardShortcutSettings ()
const handlersRef = useRef (handlers)
const actionIdsKey = Object.keys (handlers).sort ().join ('|')
useEffect (() => {
handlersRef.current = handlers
}, [handlers])
const stableHandlers = useMemo<ShortcutHandlers> (() => {
return actionIdsKey.split ('|').filter (actionId => actionId !== '')
.reduce<ShortcutHandlers> ((nextHandlers, actionId) => {
const key = actionId as ShortcutActionId
nextHandlers[key] = () => {
handlersRef.current[key]?.()
}
return nextHandlers
}, { })
}, [actionIdsKey])
useEffect ( useEffect (
() => registerHandlers (handlers), () => registerHandlers (stableHandlers),
[handlers, registerHandlers], [registerHandlers, stableHandlers],
) )
} }
+69
ファイルの表示
@@ -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<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean> }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
}, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
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<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true,
registerUnsavedChangesSource,
confirmDiscardNavigation,
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
</UnsavedChangesGuardContext.Provider>)
}
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
const context = useContext (UnsavedChangesGuardContext)
if (context == null)
throw new Error ('UnsavedChangesGuardProvider が必要です.')
return context
}
+7 -1
ファイルの表示
@@ -28,6 +28,7 @@ import {
getClientGekanatorBackgroundMotion, getClientGekanatorBackgroundMotion,
setClientGekanatorBackgroundMotion, setClientGekanatorBackgroundMotion,
} from '@/lib/settings' } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -3677,6 +3678,8 @@ const expectedAnswerFor = (
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const storedGame = useMemo (loadStoredGame, []) const storedGame = useMemo (loadStoredGame, [])
const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase) const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
const queryClient = useQueryClient () const queryClient = useQueryClient ()
@@ -4069,9 +4072,12 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
return reviewGuessedPostId === reviewCorrectPostId return reviewGuessedPostId === reviewCorrectPostId
}) () }) ()
const effectiveBackgroundMotionMode = (() => { const effectiveBackgroundMotionMode = (() => {
if (animationMode === 'off')
return 'off'
if (backgroundMotionMode === 'off') if (backgroundMotionMode === 'off')
return 'off' return 'off'
if (prefersReducedMotion) if (animationMode === 'reduced' || prefersReducedMotion)
return 'calm' return 'calm'
return backgroundMotionMode return backgroundMotionMode
+7 -2
ファイルの表示
@@ -9,14 +9,18 @@ import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchMaterialChanges } from '@/lib/materials' import { fetchMaterialChanges } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
const MaterialHistoryPage: FC = () => { const MaterialHistoryPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
@@ -55,8 +59,9 @@ const MaterialHistoryPage: FC = () => {
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => { useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({
}, [location.search]) behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search])
const handleSearch = (event: FormEvent) => { const handleSearch = (event: FormEvent) => {
event.preventDefault () event.preventDefault ()
+14 -4
ファイルの表示
@@ -12,9 +12,12 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPostChanges, updatePost } from '@/lib/posts' import { fetchPostChanges, updatePost } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags' import { fetchTag } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils' import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
@@ -37,6 +40,12 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
const PostHistoryPage: FC = () => { const PostHistoryPage: FC = () => {
const dialogue = useDialogue () 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 location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
@@ -113,8 +122,9 @@ const PostHistoryPage: FC = () => {
} }
useEffect (() => { useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({
}, [location.search]) behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search])
const layoutIds: string[] = [] const layoutIds: string[] = []
@@ -198,8 +208,8 @@ const PostHistoryPage: FC = () => {
rowSpan={rowsCnt}> rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.postId }`}> <PrefetchLink to={`/posts/${ change.postId }`}>
<motion.div <motion.div
layoutId={layoutId} layoutId={animationMode === 'off' ? undefined : layoutId}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}> transition={{ layout: layoutTransition }}>
<img src={change.thumbnail.current <img src={change.thumbnail.current
|| change.thumbnailBase.current || change.thumbnailBase.current
|| undefined} || undefined}
+40 -1
ファイルの表示
@@ -1,9 +1,10 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostListPage from '@/pages/posts/PostListPage' import PostListPage from '@/pages/posts/PostListPage'
import { buildPost, buildTag, buildWikiPage } from '@/test/factories' import { buildPost, buildTag, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const postsApi = vi.hoisted (() => ({ const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (), fetchPosts: vi.fn (),
@@ -18,6 +19,11 @@ vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/wiki', () => wikiApi) vi.mock ('@/lib/wiki', () => wikiApi)
describe ('PostListPage', () => { describe ('PostListPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('loads posts from the current query and renders the plaza tab', async () => { it ('loads posts from the current query and renders the plaza tab', async () => {
const tag = buildTag ({ name: '虹夏' }) const tag = buildTag ({ name: '虹夏' })
postsApi.fetchPosts.mockResolvedValueOnce ({ postsApi.fetchPosts.mockResolvedValueOnce ({
@@ -36,6 +42,7 @@ describe ('PostListPage', () => {
match: 'all', match: 'all',
page: 2, page: 2,
limit: 20, limit: 20,
order: 'original_created_at:desc',
}), }),
) )
}) })
@@ -71,4 +78,36 @@ describe ('PostListPage', () => {
'/wiki/%E8%99%B9%E5%A4%8F', '/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 (<PostListPage/>, { 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 (<PostListPage/>, { route: '/posts?page=3' })
fireEvent.change (await screen.findByLabelText ('件数'), { target: { value: '100' } })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
}) })
+1 -9
ファイルの表示
@@ -30,16 +30,12 @@ describe ('PostNewPage', () => {
expect (screen.getByText ('403')).toBeInTheDocument () 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.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
@@ -97,10 +93,6 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
+40
ファイルの表示
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostSearchPage from '@/pages/posts/PostSearchPage' import PostSearchPage from '@/pages/posts/PostSearchPage'
import { buildPost, buildTag } from '@/test/factories' import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const postsApi = vi.hoisted (() => ({ const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (), fetchPosts: vi.fn (),
@@ -18,6 +19,7 @@ vi.mock ('@/lib/api', () => api)
describe ('PostSearchPage', () => { describe ('PostSearchPage', () => {
beforeEach (() => { beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks () vi.clearAllMocks ()
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
}) })
@@ -40,6 +42,7 @@ describe ('PostSearchPage', () => {
tags: 'x', tags: 'x',
match: 'any', match: 'any',
page: 2, page: 2,
limit: 20,
}), }),
) )
}) })
@@ -81,4 +84,41 @@ describe ('PostSearchPage', () => {
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () 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 (
<PostSearchPage/>,
{ 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 (<PostSearchPage/>, { 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 }),
)
})
})
}) })
+19 -8
ファイルの表示
@@ -14,6 +14,8 @@ import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys } from '@/lib/queryKeys'
import { import {
@@ -24,6 +26,7 @@ import {
LIST_LIMIT_OPTIONS, LIST_LIMIT_OPTIONS,
setClientListSettings, setClientListSettings,
} from '@/lib/settings' } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
@@ -67,6 +70,13 @@ const parseOrder = (value: string | null): FetchPostsOrder | null => {
const PostSearchPage: FC = () => { 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 location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -141,9 +151,11 @@ const PostSearchPage: FC = () => {
setUpdatedFrom (qUpdatedFrom) setUpdatedFrom (qUpdatedFrom)
setUpdatedTo (qUpdatedTo) setUpdatedTo (qUpdatedTo)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({
}, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom, behavior: clientScrollBehaviour (animationMode) })
qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL]) }, [animationMode, location.search, qCreatedFrom, qCreatedTo, qMatch,
qOriginalCreatedFrom, qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom,
qUpdatedTo, qURL])
const search = async () => { const search = async () => {
const qs = new URLSearchParams () const qs = new URLSearchParams ()
@@ -400,11 +412,10 @@ const PostSearchPage: FC = () => {
<td className="p-2"> <td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}> <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
<motion.div <motion.div
layoutId={`page-${ row.id }`} layoutId={animationMode === 'off'
transition={{ type: 'spring', ? undefined
stiffness: 500, : `page-${ row.id }`}
damping: 40, transition={thumbnailTransition}>
mass: .5 }}>
<img src={row.thumbnail || row.thumbnailBase || undefined} <img src={row.thumbnail || row.thumbnailBase || undefined}
alt={row.title || row.url} alt={row.title || row.url}
title={row.title || row.url || undefined} title={row.title || row.url || undefined}
+2
ファイルの表示
@@ -6,6 +6,7 @@ import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories' import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
import type { NicoTag } from '@/types' import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
@@ -27,6 +28,7 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue, useDialogue: () => dialogue,
})) }))
+7 -2
ファイルの表示
@@ -17,8 +17,10 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { extractValidationError } from '@/lib/apiErrors' import { extractValidationError } from '@/lib/apiErrors'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys' import { tagsKeys } from '@/lib/queryKeys'
import { fetchNicoTags } from '@/lib/tags' import { fetchNicoTags } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, inputClass } from '@/lib/utils' import { cn, dateString, inputClass } from '@/lib/utils'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
@@ -38,6 +40,8 @@ const setIf = (qs: URLSearchParams, key: string, value: string) => {
const NicoTagListPage: FC<Props> = ({ user }) => { const NicoTagListPage: FC<Props> = ({ user }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const dialogue = useDialogue () const dialogue = useDialogue ()
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -152,8 +156,9 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setLinkStatus (qLinkStatus) setLinkStatus (qLinkStatus)
setEditingId (null) setEditingId (null)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({
}, [location.search, qLinkedTag, qLinkStatus, qName]) behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search, qLinkedTag, qLinkStatus, qName])
useEffect (() => { useEffect (() => {
if (!(data)) if (!(data))
+7 -2
ファイルの表示
@@ -11,8 +11,10 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORY_NAMES } from '@/consts' import { CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTagChanges } from '@/lib/tags' import { fetchTagChanges } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString } from '@/lib/utils' import { cn, dateString } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -43,6 +45,8 @@ const renderStateDiff = (diff: { current: string | null; prev: string | null })
const TagHistoryPage: FC = () => { const TagHistoryPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const id = query.get ('id') const id = query.get ('id')
@@ -58,8 +62,9 @@ const TagHistoryPage: FC = () => {
const qc = useQueryClient () const qc = useQueryClient ()
useEffect (() => { useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({
}, [location.search]) behavior: clientScrollBehaviour (animationMode) })
}, [animationMode, location.search])
return ( return (
<MainArea> <MainArea>
+48 -3
ファイルの表示
@@ -1,9 +1,10 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' 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 TagListPage from '@/pages/tags/TagListPage'
import { buildTag } from '@/test/factories' import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const tagsApi = vi.hoisted (() => ({ const tagsApi = vi.hoisted (() => ({
fetchTags: vi.fn (), fetchTags: vi.fn (),
@@ -12,6 +13,11 @@ const tagsApi = vi.hoisted (() => ({
vi.mock ('@/lib/tags', () => tagsApi) vi.mock ('@/lib/tags', () => tagsApi)
describe ('TagListPage', () => { describe ('TagListPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('loads tags from URL filters and renders the results table', async () => { it ('loads tags from URL filters and renders the results table', async () => {
tagsApi.fetchTags.mockResolvedValueOnce ({ tagsApi.fetchTags.mockResolvedValueOnce ({
tags: [buildTag ({ tags: [buildTag ({
@@ -38,6 +44,8 @@ describe ('TagListPage', () => {
name: '虹', name: '虹',
category: 'character', category: 'character',
page: 3, page: 3,
limit: 20,
order: 'post_count:desc',
postCountGTE: 5, postCountGTE: 5,
deprecated: true, deprecated: true,
}), }),
@@ -48,7 +56,7 @@ describe ('TagListPage', () => {
'/tags/7', '/tags/7',
) )
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0) 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) expect (screen.getAllByText ('廃止')).toHaveLength (2)
}) })
@@ -58,7 +66,7 @@ describe ('TagListPage', () => {
renderWithProviders (<TagListPage/>, { route: '/tags' }) renderWithProviders (<TagListPage/>, { route: '/tags' })
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } }) fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
fireEvent.change (screen.getAllByRole ('combobox')[0], { fireEvent.change (screen.getAllByRole ('combobox')[1], {
target: { value: 'character' }, target: { value: 'character' },
}) })
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!) fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
@@ -82,4 +90,41 @@ describe ('TagListPage', () => {
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () 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 (
<TagListPage/>,
{ 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 (<TagListPage/>, { 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 }),
)
})
})
}) })
+9 -3
ファイルの表示
@@ -13,6 +13,7 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys' import { tagsKeys } from '@/lib/queryKeys'
import { import {
DEFAULT_POST_LIST_LIMIT, DEFAULT_POST_LIST_LIMIT,
@@ -22,6 +23,7 @@ import {
LIST_LIMIT_OPTIONS, LIST_LIMIT_OPTIONS,
setClientListSettings, setClientListSettings,
} from '@/lib/settings' } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchTags } from '@/lib/tags' import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
@@ -71,6 +73,8 @@ const parseOrder = (value: string | null): FetchTagsOrder | null => {
const TagListPage: FC = () => { const TagListPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -145,9 +149,11 @@ const TagListPage: FC = () => {
setUpdatedTo (qUpdatedTo) setUpdatedTo (qUpdatedTo)
setDeprecated (qDeprecated) setDeprecated (qDeprecated)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE, behavior: clientScrollBehaviour (animationMode) })
qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated]) }, [animationMode, location.search, qCategory, qCreatedFrom, qCreatedTo,
qName, qPostCountGTE, qPostCountLTE, qUpdatedFrom, qUpdatedTo,
qDeprecated])
const handleSearch = (e: FormEvent) => { const handleSearch = (e: FormEvent) => {
e.preventDefault () e.preventDefault ()
+1
ファイルの表示
@@ -41,6 +41,7 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue, useDialogue: () => dialogue,
})) }))
vi.mock ('@/components/PostEmbed', () => ({ vi.mock ('@/components/PostEmbed', () => ({
+15 -6
ファイルの表示
@@ -15,6 +15,8 @@ import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPost } from '@/lib/posts' import { fetchPost } from '@/lib/posts'
import { import {
getClientTheatreLayoutMode, getClientTheatreLayoutMode,
@@ -22,6 +24,7 @@ import {
setClientTheatreLayoutMode, setClientTheatreLayoutMode,
setClientTheatreTagFlow, setClientTheatreTagFlow,
} from '@/lib/settings' } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils' import { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -57,8 +60,6 @@ const INITIAL_THEATRE_INFO: TheatreInfo =
const INITIAL_WEIGHTS: TheatrePostSelectionWeights = const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] } { tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const MIN_MAIN_WIDTH = 360 const MIN_MAIN_WIDTH = 360
const SIDEBAR_GAP_WIDTH = 16 const SIDEBAR_GAP_WIDTH = 16
@@ -214,6 +215,12 @@ type Props = { user: User | null }
const TheatreDetailPage: FC<Props> = ({ user }: Props) => { const TheatreDetailPage: FC<Props> = ({ 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 { id } = useParams ()
const dialogue = useDialogue () const dialogue = useDialogue ()
@@ -620,7 +627,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
clearValidationErrors () clearValidationErrors ()
await apiPost (`/theatres/${ id }/comments`, { content }) await apiPost (`/theatres/${ id }/comments`, { content })
setContent ('') setContent ('')
commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) commentsRef.current?.scrollTo ({
top: 0,
behavior: clientScrollBehaviour (animationMode) })
} }
catch (error) catch (error)
{ {
@@ -837,8 +846,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return ( return (
<motion.div <motion.div
layout="position" layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: { duration: .2, ease: 'easeOut' } }} transition={{ layout: layoutTransition }}
className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950 className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50"> md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
<Helmet> <Helmet>
@@ -862,7 +871,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</SidebarComponent>)} </SidebarComponent>)}
<motion.main <motion.main
layout="position" layout={animationMode === 'off' ? false : 'position'}
className="order-1 min-w-0 flex-1 space-y-4 md:order-none className="order-1 min-w-0 flex-1 space-y-4 md:order-none
md:min-w-[360px] md:overflow-y-auto"> md:min-w-[360px] md:overflow-y-auto">
<div className="space-y-4"> <div className="space-y-4">
+22 -8
ファイルの表示
@@ -6,6 +6,7 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPut: vi.fn (), apiPut: vi.fn (),
isApiError: vi.fn (), isApiError: vi.fn (),
})) }))
@@ -27,12 +28,23 @@ describe ('SettingPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
api.isApiError.mockReturnValue (false) 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', () => { it ('shows loading when user is absent', () => {
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>) renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
expect (screen.getByText ('Loading...')).toBeInTheDocument () expect (screen.getAllByText ('Loading...').length).toBeGreaterThan (0)
}) })
it ('updates the current user name', async () => { it ('updates the current user name', async () => {
@@ -42,8 +54,9 @@ describe ('SettingPage', () => {
renderWithProviders (<SettingPage user={user} setUser={setUser}/>) renderWithProviders (<SettingPage user={user} setUser={setUser}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'new' } }) const nameInput = (await screen.findAllByRole ('textbox'))[0]
fireEvent.click (screen.getByRole ('button', { name: '更新' })) fireEvent.change (nameInput, { target: { value: 'new' } })
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
await waitFor (() => { await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ( expect (api.apiPut).toHaveBeenCalledWith (
@@ -55,7 +68,7 @@ describe ('SettingPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('name')).toBe ('new') expect (formData.get ('name')).toBe ('new')
expect (setUser).toHaveBeenCalled () expect (setUser).toHaveBeenCalled ()
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' })
}) })
it ('shows validation errors returned for the name field', async () => { it ('shows validation errors returned for the name field', async () => {
@@ -75,10 +88,11 @@ describe ('SettingPage', () => {
renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>) renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } }) const nameInput = (await screen.findAllByRole ('textbox'))[0]
fireEvent.click (screen.getByRole ('button', { name: '更新' })) fireEvent.change (nameInput, { target: { value: '' } })
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument () expect ((await screen.findAllByText ('名前は必須です.')).length).toBeGreaterThan (0)
expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true') expect (nameInput).toHaveAttribute ('aria-invalid', 'true')
}) })
}) })
ファイル差分が大きすぎるため省略します 差分を読込み
+13 -5
ファイルの表示
@@ -3,6 +3,10 @@ import { render } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async' import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter } from 'react-router-dom' 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' import type { ReactElement, ReactNode } from 'react'
type Options = { type Options = {
@@ -15,21 +19,25 @@ export const renderWithProviders = (
) => { ) => {
const queryClient = new QueryClient ({ const queryClient = new QueryClient ({
defaultOptions: { defaultOptions: {
queries: { retry: false }, queries: { retry: false } },
},
}) })
const Wrapper = ({ children }: { children: ReactNode }) => ( const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<HelmetProvider> <HelmetProvider>
<MemoryRouter initialEntries={[options.route ?? '/']}> <MemoryRouter initialEntries={[options.route ?? '/']}>
{children} <DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</MemoryRouter> </MemoryRouter>
</HelmetProvider> </HelmetProvider>
</QueryClientProvider>) </QueryClientProvider>)
return { return {
queryClient, queryClient,
...render (ui, { wrapper: Wrapper }), ...render (ui, { wrapper: Wrapper }) }
}
} }
+7
ファイルの表示
@@ -20,3 +20,10 @@ window.requestAnimationFrame = callback => {
callback (0) callback (0)
return 0 return 0
} }
Object.defineProperty (window, 'matchMedia', {
configurable: true,
value: vi.fn ().mockImplementation (() => ({
addEventListener: vi.fn (),
removeEventListener: vi.fn (),
matches: false })) })