コミットを比較

..

17 コミット

作成者 SHA1 メッセージ 日付
みてるぞ b0cce4b7ee #34 2026-07-05 12:51:31 +09:00
みてるぞ 40cc3b2c0c Merge remote-tracking branch 'origin/main' into feature/034 2026-07-05 11:59:28 +09:00
みてるぞ 61ab4744bd #34 2026-07-05 11:57:29 +09:00
みてるぞ ca92c62049 #34 2026-07-05 11:27:45 +09:00
みてるぞ 4e3fb6bef7 #34 2026-07-05 03:43:37 +09:00
みてるぞ 15619886f2 サイド・バーの幅を可変に (#385) (#396)
Reviewed-on: #396
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-05 03:38:01 +09:00
みてるぞ 3a9ac8af32 #34 2026-07-05 03:27:58 +09:00
みてるぞ 7bc9ee1e68 #34 2026-07-05 03:19:58 +09:00
みてるぞ 95b4db1fc2 #34 2026-07-05 02:59:48 +09:00
みてるぞ 4117bdd31e #34 2026-07-05 02:40:19 +09:00
みてるぞ fecbb4b354 #34 2026-07-05 02:04:07 +09:00
みてるぞ d0ea329887 #34 2026-07-05 01:51:07 +09:00
みてるぞ 3b29c3e10e #34 2026-07-05 00:03:13 +09:00
みてるぞ 518c5fa0f2 #34 2026-07-04 21:47:16 +09:00
みてるぞ 4cc2dc5441 #34 2026-07-04 19:50:40 +09:00
みてるぞ 6a57dc53bf #34 2026-07-04 19:03:50 +09:00
みてるぞ fdf652951a 【再掲】タグの局所記載 (#351) (#380)
#353 を再開できなかったので.

Reviewed-on: #380
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-03 03:23:36 +09:00
47個のファイルの変更5680行の追加550行の削除
+436 -17
ファイルの表示
@@ -106,6 +106,10 @@ npm run preview
- Explain risks directly.
- Prefer single quotes for strings unless interpolation or escaping makes
double quotes better.
- For Japanese text, follow the 1986 Cabinet Notice
《現代仮名遣い》 as the default orthography.
- For Japanese kanji spelling, do not apply
《当用漢字による書きかえ》; prefer the original spelling as the formal one.
- Ruby: never put a space before method-call parentheses.
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
- Ruby: never put a line break immediately before `)`.
@@ -200,7 +204,7 @@ const value =
## Backend rules
- Inspect existing routes, controllers, models, services, and specs before
editing backend behavior.
editing backend behaviour.
- Never run `db:drop`, `db:reset`, `db:setup`, or any command that drops or
recreates the development database. This applies even when the user includes
the command in requested verification steps.
@@ -211,7 +215,7 @@ const value =
- Repeated destructive instructions are not enough confirmation because they
may be auto-generated. Without `いいからやれ`, refuse or substitute a safer
test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`.
- For API behavior changes, add or update request specs under
- For API behaviour changes, add or update request specs under
`backend/spec/requests` only when the user explicitly asks for tests.
- Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage.
@@ -219,8 +223,8 @@ const value =
- Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
wiki revisions, and restore/diff behaviour.
- Be careful with tag names, tag normalisation, implications, similarities, and discard behaviour.
- Be sensitive to N+1 queries; avoid introducing them and proactively fix
existing N+1 issues in the code path being edited.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
@@ -241,9 +245,9 @@ const value =
- Internal links must use `PrefetchLink`.
- External links must use `<a>` with `target="_blank"`.
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an
explicit readable `text-*` color and dark-mode counterparts such as
explicit readable `text-*` colour and dark-mode counterparts such as
`dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present.
- Do not rely on inherited text color for light backgrounds. This is especially
- Do not rely on inherited text colour for light backgrounds. This is especially
important for chips, cards, buttons, and panels that may inherit white text in
dark mode.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
@@ -255,8 +259,11 @@ const value =
`!==` over negating a comparison like `!(a === b)`.
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
simple unit-step counter updates.
- For user-facing Japanese text, prefer modern kana usage and natural current
phrasing over historical spellings or awkward literal wording.
- For user-facing Japanese text, follow the 1986 Cabinet Notice
《現代仮名遣い》 and avoid historical kana spellings unless the task
explicitly requires them.
- For user-facing Japanese kanji spelling, do not normalize to
《当用漢字による書きかえ》; prefer original forms such as `編輯`.
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
### Frontend TSX style
@@ -264,6 +271,33 @@ const value =
- Preserve the local TSX formatting style.
- Do not normalize TSX to common Prettier-style React formatting unless
explicitly asked.
- Treat TSX formatting rules as hard constraints, not preferences. Before
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}`
placement and fix violations instead of relying on formatter defaults.
- After every TSX edit, perform a style-only self-review of the edited hunks
before running verification or reporting completion. The task is not complete
while any edited TSX hunk violates these local formatting rules.
- The TSX self-review must classify every edited leading or trailing `)`, `]`,
and `}` by syntax role before deciding whether it is valid. Do not apply a
rule by glyph alone. A closing `)` for a function parameter list is different
from a closing `)` for a function call. A closing `}` for a block is different
from a closing `}` for an object, type literal, import list, or destructuring
pattern.
- The TSX self-review must confirm there are no common Prettier-style React
component declarations with a multi-line destructured parameter.
- The TSX self-review must confirm multi-line function declaration parameter
`)` placement follows the detailed parameter-list rules below.
- The TSX self-review must confirm call-expression `)` is never at the
beginning of a line.
- The TSX self-review must confirm object/type/import/destructuring `}` is not
at the beginning of a line.
- The TSX self-review must confirm multi-line function/lambda/callback/block
`}` is on its own line and never at the end of the previous line.
- The TSX self-review must confirm array `]` is not at the beginning of a line.
- The TSX self-review must confirm JSX closing markers and closing parentheses
keep the surrounding compact style.
- The TSX self-review must confirm leading indentation follows 4-space logical
indentation with tabs only as leading 8-space compression.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
@@ -272,16 +306,37 @@ const value =
character.
- A leading tab is exactly equivalent to 8 leading spaces.
- In TypeScript and TSX function declarations, including `const` arrow
function declarations, when the parameter list spans multiple lines, always
put the closing parenthesis at the beginning of its own line before the return
type or `=>`.
function declarations, classify the parameter list before placing the closing
`)`.
- If the parameter list itself is given its own multi-line block after the
function's opening `(`, put the closing parameter `)` at the beginning of its
own line before the return type or `=>`.
- If the only line break is inside a parameter's inline type, object type, or
destructuring shape, and the parameter list itself is not split as a separate
block, keep the closing parameter `)` on the same line as that parameter's
final `}`. In this case, moving `)` to a new line is wrong.
- Do not write React component declarations in the common Prettier form
`const Component = ({ ... }) => (` when the destructured parameter spans
multiple lines. Use the project form with the opening `(` after `=`, the
destructured object as the argument, and the closing parameter `)` on its own
line before `=>`.
- In TypeScript and TSX, never place a closing parenthesis at the beginning of
a line except for a multi-line function declaration parameter list.
- Never place a closing square bracket at the beginning of a line.
- For object literals and other associative-array-style braces, do not place
the closing brace at the beginning of a line. Function, lambda, callback, and
block closing braces are exempt and should stay on their own line when that
fits the local style.
- For object literals, type literals, import/export named bindings, destructuring
patterns, and other associative-array-style braces, do not place the closing
brace at the beginning of a line. Keep `}` on the same line as the final
property, binding, or specifier unless that would violate the line limit.
Function, lambda, callback, and block closing braces are exempt and should
stay on their own line when that fits the local style. When a function,
lambda, callback, or block body spans multiple lines, do not put its closing
`}` at the end of the previous line.
- For arrays and tuple-like lists, do not place the closing `]` at the
beginning of a line. Keep `]` on the same line as the final element unless
that would violate the line limit.
- For function and method calls, do not place the closing `)` at the beginning
of a line. The only TypeScript/TSX exception is the closing parameter `)` of a
multi-line function declaration.
- When writing braces on a single line in TypeScript or TSX JavaScript
context, put exactly one space inside the braces, as in `{ value }` or
`{ key: value }`.
@@ -300,6 +355,370 @@ const value =
- Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`.
- Use correct British English spelling for new identifiers, filenames,
component names, helper names, comments, and developer-facing prose unless
editing an already established American-English API that must keep its
existing spelling for compatibility.
- Prefer British English spellings such as `behaviour`, `colour`, `realise`,
`theatre`, `centre`, `favourite`, `optimise`, and `catalogue`.
- Avoid American or Canadian spellings such as `behavior`, `color`, `realize`,
`theater`, `center`, `favorite`, `optimize`, and `catalog`.
- Even when an external library or API uses the wrong spelling, prefer to
correct it at the local boundary and use proper British English names in this
codebase. For example, prefer `import { color: colour } from '@external-lib'`
over spreading `color` through local code.
- Apply the same boundary correction to object destructuring, wrapper helpers,
adapter layers, and local variable names when doing so does not break the
external contract.
- For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not
`BehaviorSettingsSection.tsx`.
### Frontend delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit.
#### Import and export named bindings
Bad:
```ts
import {
Button,
Card,
} from '@/components/ui'
```
Good:
```ts
import { Button,
Card } from '@/components/ui'
```
Also acceptable when short:
```ts
import { Button, Card } from '@/components/ui'
```
Rule: named-binding `}` is associative-array-style syntax. It must not be alone
at the beginning of a line. Prefer keeping `{` with the first binding and `}`
with the final binding when this fits the line limit.
#### Type literals
Bad:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
}
```
Good:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void }
```
Rule: type-literal `}` is not a block close. It must stay on the same line as
the final property unless that would break the hard line limit.
#### Object literals
Bad:
```ts
const value = {
open,
activeScope,
}
```
Good:
```ts
const value = {
open,
activeScope }
```
Bad:
```ts
const value = useMemo (() => ({
open,
activeScope,
}), [open, activeScope])
```
Good:
```ts
const value = useMemo (() => ({
open,
activeScope }), [open, activeScope])
```
Rule: object-literal `}` is associative-array-style syntax. It must not be on a
line by itself. Keep it with the final property, and keep call `)` off the
beginning of a line.
#### Destructuring parameters
Bad:
```tsx
const Component: FC<Props> = ({
open,
onOpenChange,
}) => {
return null
}
```
Good:
```tsx
const Component: FC<Props> = (
{ open,
onOpenChange },
) => {
return null
}
```
Rule: when a React component or helper takes a destructured object parameter
that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>`
shape. Put the function parameter list on its own lines. The destructuring `}`
stays with the final binding. The parameter-list `)` is then allowed and
required at the beginning of its own line.
#### Inline typed destructuring parameter
Good:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
return null
}
```
Bad:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>>
}) => {
return null
}
```
Rule: this is not a separately split parameter-list block. The line break is
inside the inline type. Keep the type-literal `}` and parameter-list `)` on the
same line as the final type property.
#### Multi-line normal parameter list
Bad:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key]) => {
return null
}
```
Good:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key],
) => {
return null
}
```
Rule: when the parameter list itself is split across multiple parameter lines,
the closing parameter `)` goes at the beginning of its own line before `=>` or
the return type.
#### Function and callback blocks
Bad:
```ts
const handleSave = () => { save ()
}
```
Bad:
```ts
const handleSave = () => {
save () }
```
Good:
```ts
const handleSave = () => {
save ()
}
```
Rule: block `}` closes executable code, not associative data. Multi-line
function, lambda, callback, `if`, `for`, `switch`, and similar block braces
belong on their own line.
#### Function and method calls
Bad:
```ts
const value = compute (
first,
second
)
```
Good:
```ts
const value = compute (
first,
second)
```
Rule: call-expression `)` must not be at the beginning of a line. The exception
for leading `)` applies only to function declaration parameter lists, never to
calls.
#### JSX closing markers
Bad:
```tsx
<Button
type="button"
onClick={handleSave}
>
</Button>
)
```
Good:
```tsx
<Button
type="button"
onClick={handleSave}>
</Button>)
```
Bad:
```tsx
<Input
value={value}
onChange={handleChange}
/>
```
Good:
```tsx
<Input
value={value}
onChange={handleChange}/>
```
Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in
the local compact form such as `</div>)`.
#### Arrays and tuples
Bad:
```ts
const items = [
first,
second,
]
```
Good:
```ts
const items = [
first,
second]
```
Good when short:
```ts
const items = [first, second]
```
Rule: array and tuple `]` must not be at the beginning of a line. Keep it with
the final element unless that would break the hard line limit.
#### Single-line braces
Good:
```ts
const next = { ...current, enabled: true }
const tag = { id, name }
```
Bad:
```tsx
<Component prop={ value }/>
```
Good:
```tsx
<Component prop={value}/>
```
Rule: JavaScript object braces on one line get one inner space. JSX expression
braces do not get inner spaces.
#### Final TSX self-review checklist
Before reporting completion after a TypeScript or TSX edit, check the edited
hunks line by line:
1. No import/export/type/object/destructuring `}` appears alone at the beginning
of a line.
2. No call-expression `)` appears at the beginning of a line.
3. Any leading `)` is definitely closing a function declaration parameter list,
not a call.
4. Any parameter-list `)` at the end of a line is valid because the parameter
list itself was not split, only an inline type or destructuring shape was.
5. No array or tuple `]` appears at the beginning of a line.
6. Multi-line executable block `}` is on its own line.
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading indentation is 4-space logical indentation with tabs used only as
leading 8-space compression.
10. No line has trailing whitespace.
Preferred:
@@ -335,7 +754,7 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
- Do not scan or summarize dependency/generated/runtime directories such as
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
behavior, inspect the related request specs and service objects.
behaviour, inspect the related request specs and service objects.
- If frontend code changes, run only non-test verification commands that
apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
only when the user explicitly asks for tests.
@@ -352,4 +771,4 @@ A task is complete only when:
explained,
- unrelated files are not changed,
- migrations and schema are consistent when schema changes are made,
- user-facing behavior is documented when needed.
- user-facing behaviour is documented when needed.
+61
ファイルの表示
@@ -0,0 +1,61 @@
class UserSettingsController < ApplicationController
wrap_parameters false
def show
return head :unauthorized unless current_user
render json: current_setting.serializable_hash
end
def update
return head :unauthorized unless current_user
raw_attributes = editable_raw_attributes
field_errors = validate_raw_attributes(raw_attributes)
return render_validation_error fields: field_errors if field_errors.present?
setting = current_setting
setting.assign_attributes(raw_attributes)
if setting.save
render json: setting.serializable_hash, status: :ok
else
render_validation_error setting
end
end
private
def current_setting
Setting.find_or_create_by!(user: current_user) do |setting|
setting.assign_attributes(Setting.defaults)
end
rescue ActiveRecord::RecordNotUnique
Setting.find_by!(user: current_user)
end
def editable_raw_attributes
request.request_parameters.slice(*Setting::EDITABLE_ATTRIBUTES)
end
def validate_raw_attributes raw_attributes
raw_attributes.each_with_object({ }) do |(key, value), errors|
next if value_matches_type?(key, value)
errors[key.to_sym] = ['値の型が不正です.']
end
end
def value_matches_type? key, value
case Setting::TYPE_BY_ATTRIBUTE.fetch(key)
when :string
value.is_a?(String)
when :integer
value.is_a?(Integer)
when :boolean
value == true || value == false
else
false
end
end
end
+47 -2
ファイルの表示
@@ -1,7 +1,52 @@
class Setting < ApplicationRecord
THEMES = ['system', 'light', 'dark'].freeze
# These remain in the typed settings schema to preserve existing backend
# work. The common `/users/settings` page currently surfaces only `theme`.
AUTO_FETCH_MODES = ['auto', 'manual', 'off'].freeze
WIKI_EDITOR_MODES = ['split', 'write', 'preview'].freeze
STRING_ATTRIBUTES = [
'theme',
'auto_fetch_title',
'auto_fetch_thumbnail',
'wiki_editor_mode',
].freeze
INTEGER_ATTRIBUTES = [].freeze
BOOLEAN_ATTRIBUTES = [].freeze
EDITABLE_ATTRIBUTES =
(STRING_ATTRIBUTES + INTEGER_ATTRIBUTES + BOOLEAN_ATTRIBUTES).freeze
TYPE_BY_ATTRIBUTE = {
'theme' => :string,
'auto_fetch_title' => :string,
'auto_fetch_thumbnail' => :string,
'wiki_editor_mode' => :string,
}.freeze
belongs_to :user
validates :user_id, presence: true
validates :key, presence: true, length: { maximum: 255 }
validates :value, presence: true
validates :user_id, uniqueness: true
validates :theme, inclusion: { in: THEMES }
validates :auto_fetch_title, inclusion: { in: AUTO_FETCH_MODES }
validates :auto_fetch_thumbnail, inclusion: { in: AUTO_FETCH_MODES }
validates :wiki_editor_mode, inclusion: { in: WIKI_EDITOR_MODES }
def self.defaults
{
theme: 'system',
auto_fetch_title: 'manual',
auto_fetch_thumbnail: 'manual',
wiki_editor_mode: 'split',
}
end
def self.serializable_attributes
EDITABLE_ATTRIBUTES.map(&:to_sym)
end
def serializable_hash(options = nil)
super({ only: self.class.serializable_attributes }.merge(options || { }))
end
end
+1 -1
ファイルの表示
@@ -7,7 +7,7 @@ class User < ApplicationRecord
has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
has_many :settings
has_one :setting, dependent: :destroy
has_many :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy
+3
ファイルの表示
@@ -81,6 +81,9 @@ Rails.application.routes.draw do
end
end
get 'users/settings', to: 'user_settings#show'
patch 'users/settings', to: 'user_settings#update'
resources :users, only: [:create, :update] do
collection do
post :verify
+31
ファイルの表示
@@ -0,0 +1,31 @@
class RebuildSettingsAsTypedUserSettings < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :settings, :users if foreign_key_exists?(:settings, :users)
remove_index :settings, :user_id if index_exists?(:settings, :user_id)
remove_column :settings, :key, :string if column_exists?(:settings, :key)
remove_column :settings, :value, :json if column_exists?(:settings, :value)
change_column_null :settings, :user_id, false
add_column :settings, :theme, :string, null: false, default: 'system'
add_column :settings,
:auto_fetch_title,
:string,
null: false,
default: 'manual'
add_column :settings,
:auto_fetch_thumbnail,
:string,
null: false,
default: 'manual'
add_column :settings,
:wiki_editor_mode,
:string,
null: false,
default: 'split'
add_index :settings, :user_id, unique: true
add_foreign_key :settings, :users
end
end
生成ファイル
+6 -4
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -374,11 +374,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "key", null: false
t.json "value", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_settings_on_user_id"
t.string "theme", default: "system", null: false
t.string "auto_fetch_title", default: "manual", null: false
t.string "auto_fetch_thumbnail", default: "manual", null: false
t.string "wiki_editor_mode", default: "split", null: false
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+425 -1
ファイルの表示
@@ -118,7 +118,7 @@ pass or the remaining failure is clearly blocked.
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist
if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
@@ -126,15 +126,439 @@ pass or the remaining failure is clearly blocked.
- Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them.
- Treat TSX formatting rules as hard constraints, not preferences. Before
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}`
placement and fix violations instead of relying on formatter defaults.
- After every TSX edit, perform a style-only self-review of the edited hunks
before running verification or reporting completion. The task is not complete
while any edited TSX hunk violates these local formatting rules.
- The TSX self-review must classify every edited leading or trailing `)`, `]`,
and `}` by syntax role before deciding whether it is valid. Do not apply a
rule by glyph alone. A closing `)` for a function parameter list is different
from a closing `)` for a function call. A closing `}` for a block is different
from a closing `}` for an object, type literal, import list, or destructuring
pattern.
- The TSX self-review must confirm there are no common Prettier-style React
component declarations with a multi-line destructured parameter.
- The TSX self-review must confirm multi-line function declaration parameter
`)` placement follows the detailed parameter-list rules below.
- The TSX self-review must confirm call-expression `)` is never at the
beginning of a line.
- The TSX self-review must confirm object/type/import/destructuring `}` is not
at the beginning of a line.
- The TSX self-review must confirm multi-line function/lambda/callback/block
`}` is on its own line and never at the end of the previous line.
- The TSX self-review must confirm array `]` is not at the beginning of a line.
- The TSX self-review must confirm JSX closing markers and closing parentheses
keep the surrounding compact style.
- The TSX self-review must confirm leading indentation follows 4-space logical
indentation with tabs only as leading 8-space compression.
- For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression.
- In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing
`)`.
- If the parameter list itself is given its own multi-line block after the
function's opening `(`, put the closing parameter `)` at the beginning of its
own line before the return type or `=>`.
- If the only line break is inside a parameter's inline type, object type, or
destructuring shape, and the parameter list itself is not split as a separate
block, keep the closing parameter `)` on the same line as that parameter's
final `}`. In this case, moving `)` to a new line is wrong.
- Do not write React component declarations in the common Prettier form
`const Component = ({ ... }) => (` when the destructured parameter spans
multiple lines. Use the project form with the opening `(` after `=`, the
destructured object as the argument, and the closing parameter `)` on its own
line before `=>`.
- In TypeScript and TSX, never place a closing parenthesis at the beginning of
a line except for a multi-line function declaration parameter list.
- Never place a closing square bracket at the beginning of a line.
- For object literals, type literals, import/export named bindings, destructuring
patterns, and other associative-array-style braces, do not place the closing
brace at the beginning of a line. Keep `}` on the same line as the final
property, binding, or specifier unless that would violate the line limit.
Function, lambda, callback, and block closing braces are exempt and should
stay on their own line when that fits the local style. When a function,
lambda, callback, or block body spans multiple lines, do not put its closing
`}` at the end of the previous line.
- For arrays and tuple-like lists, do not place the closing `]` at the
beginning of a line. Keep `]` on the same line as the final element unless
that would violate the line limit.
- For function and method calls, do not place the closing `)` at the beginning
of a line. The only TypeScript/TSX exception is the closing parameter `)` of a
multi-line function declaration.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- Use correct British English spelling for new identifiers, filenames,
component names, helper names, comments, and developer-facing prose unless
editing an already established American-English API that must keep its
existing spelling for compatibility.
- Prefer British English spellings such as `behaviour`, `colour`, `realise`,
`theatre`, `centre`, `favourite`, `optimise`, and `catalogue`.
- Avoid American or Canadian spellings such as `behavior`, `color`, `realize`,
`theater`, `center`, `favorite`, `optimize`, and `catalog`.
- Even when an external library or API uses the wrong spelling, prefer to
correct it at the local boundary and use proper British English names in this
frontend codebase. For example, prefer
`import { color: colour } from '@external-lib'` over spreading `color`
through local code.
- Apply the same boundary correction to object destructuring, wrapper helpers,
adapter layers, and local variable names when doing so does not break the
external contract.
- In this frontend, prefer names such as `BehaviourSettingsSection.tsx`, not
`BehaviorSettingsSection.tsx`.
- Avoid reformatting unrelated JSX.
### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit.
#### Import and export named bindings
Bad:
```ts
import {
Button,
Card,
} from '@/components/ui'
```
Good:
```ts
import { Button,
Card } from '@/components/ui'
```
Also acceptable when short:
```ts
import { Button, Card } from '@/components/ui'
```
Rule: named-binding `}` is associative-array-style syntax. It must not be alone
at the beginning of a line. Prefer keeping `{` with the first binding and `}`
with the final binding when this fits the line limit.
#### Type literals
Bad:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
}
```
Good:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void }
```
Rule: type-literal `}` is not a block close. It must stay on the same line as
the final property unless that would break the hard line limit.
#### Object literals
Bad:
```ts
const value = {
open,
activeScope,
}
```
Good:
```ts
const value = {
open,
activeScope }
```
Bad:
```ts
const value = useMemo (() => ({
open,
activeScope,
}), [open, activeScope])
```
Good:
```ts
const value = useMemo (() => ({
open,
activeScope }), [open, activeScope])
```
Rule: object-literal `}` is associative-array-style syntax. It must not be on a
line by itself. Keep it with the final property, and keep call `)` off the
beginning of a line.
#### Destructuring parameters
Bad:
```tsx
const Component: FC<Props> = ({
open,
onOpenChange,
}) => {
return null
}
```
Good:
```tsx
const Component: FC<Props> = (
{ open,
onOpenChange },
) => {
return null
}
```
Rule: when a React component or helper takes a destructured object parameter
that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>`
shape. Put the function parameter list on its own lines. The destructuring `}`
stays with the final binding. The parameter-list `)` is then allowed and
required at the beginning of its own line.
#### Inline typed destructuring parameter
Good:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
return null
}
```
Bad:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>>
}) => {
return null
}
```
Rule: this is not a separately split parameter-list block. The line break is
inside the inline type. Keep the type-literal `}` and parameter-list `)` on the
same line as the final type property.
#### Multi-line normal parameter list
Bad:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key]) => {
return null
}
```
Good:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key],
) => {
return null
}
```
Rule: when the parameter list itself is split across multiple parameter lines,
the closing parameter `)` goes at the beginning of its own line before `=>` or
the return type.
#### Function and callback blocks
Bad:
```ts
const handleSave = () => { save ()
}
```
Bad:
```ts
const handleSave = () => {
save () }
```
Good:
```ts
const handleSave = () => {
save ()
}
```
Rule: block `}` closes executable code, not associative data. Multi-line
function, lambda, callback, `if`, `for`, `switch`, and similar block braces
belong on their own line.
#### Function and method calls
Bad:
```ts
const value = compute (
first,
second
)
```
Good:
```ts
const value = compute (
first,
second)
```
Rule: call-expression `)` must not be at the beginning of a line. The exception
for leading `)` applies only to function declaration parameter lists, never to
calls.
#### JSX closing markers
Bad:
```tsx
<Button
type="button"
onClick={handleSave}
>
保存
</Button>
)
```
Good:
```tsx
<Button
type="button"
onClick={handleSave}>
保存
</Button>)
```
Bad:
```tsx
<Input
value={value}
onChange={handleChange}
/>
```
Good:
```tsx
<Input
value={value}
onChange={handleChange}/>
```
Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in
the local compact form such as `</div>)`.
#### Arrays and tuples
Bad:
```ts
const items = [
first,
second,
]
```
Good:
```ts
const items = [
first,
second]
```
Good when short:
```ts
const items = [first, second]
```
Rule: array and tuple `]` must not be at the beginning of a line. Keep it with
the final element unless that would break the hard line limit.
#### Single-line braces
Good:
```ts
const next = { ...current, enabled: true }
const tag = { id, name }
```
Bad:
```tsx
<Component prop={ value }/>
```
Good:
```tsx
<Component prop={value}/>
```
Rule: JavaScript object braces on one line get one inner space. JSX expression
braces do not get inner spaces.
#### Final TSX self-review checklist
Before reporting completion after a TypeScript or TSX edit, check the edited
hunks line by line:
1. No import/export/type/object/destructuring `}` appears alone at the beginning
of a line.
2. No call-expression `)` appears at the beginning of a line.
3. Any leading `)` is definitely closing a function declaration parameter list,
not a call.
4. Any parameter-list `)` at the end of a line is valid because the parameter
list itself was not split, only an inline type or destructuring shape was.
5. No array or tuple `]` appears at the beginning of a line.
6. Multi-line executable block `}` is on its own line.
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading indentation is 4-space logical indentation with tabs used only as
leading 8-space compression.
10. No line has trailing whitespace.
## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
+76 -14
ファイルの表示
@@ -1,5 +1,5 @@
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react'
import { BrowserRouter,
Navigate,
Route,
@@ -12,6 +12,13 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import { applyClientAppearance,
fetchUserSettings,
getClientThemeMode,
seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
@@ -103,6 +110,32 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const App: FC = () => {
const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const appLayoutTransition = useMemo (
() => (
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
),
[animationMode],
)
useEffect (() => {
applyClientAppearance ()
const mediaQuery = window.matchMedia ('(prefers-color-scheme: dark)')
const handleThemeChange = () => {
if (getClientThemeMode () === 'system')
applyClientAppearance ()
}
mediaQuery.addEventListener ('change', handleThemeChange)
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, [])
useEffect (() => {
const createUser = async () => {
@@ -137,6 +170,24 @@ const App: FC = () => {
createUser ()
}, [])
useEffect (() => {
if (!(user))
return
void (async () => {
try
{
const settings = await fetchUserSettings ()
seedClientThemeMode (settings.theme)
applyClientAppearance ()
}
catch
{
return
}
}) ()
}, [user])
switch (status)
{
case 503:
@@ -149,19 +200,30 @@ const App: FC = () => {
{import.meta.env.DEV && <DevModeWatermark/>}
<BrowserRouter>
<DialogueProvider>
<LayoutGroup>
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<KeyboardShortcutsProvider>
<DialogueProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutGroup>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
</MotionConfig>
<Toaster/>
</DialogueProvider>
<Toaster/>
</DialogueProvider>
</KeyboardShortcutsProvider>
</BrowserRouter>
</>)
}
+17 -19
ファイルの表示
@@ -488,25 +488,23 @@ const MaterialSidebar: FC = () => {
</div>
</div>
<div className="hidden md:block">
<SidebarComponent>
<div className="space-y-4">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
{isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400"></p>)}
{isError && (
<p className="text-sm text-red-600 dark:text-red-300">
</p>)}
{(!isLoading && !isError) && (
<ul>
{renderDesktopTree (visibleRootTags)}
</ul>)}
</div>
</SidebarComponent>
</div>
<SidebarComponent sidebarKey="materials" className="hidden md:block">
<div className="space-y-4">
<MaterialFilterButtons
materialFilter={materialFilter}
onChange={handleFilterChange}/>
{isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400"></p>)}
{isError && (
<p className="text-sm text-red-600 dark:text-red-300">
</p>)}
{(!isLoading && !isError) && (
<ul>
{renderDesktopTree (visibleRootTags)}
</ul>)}
</div>
</SidebarComponent>
</>)
}
+61 -10
ファイルの表示
@@ -4,6 +4,8 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import type { FC, RefObject } from 'react'
@@ -24,16 +26,19 @@ type Props = {
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const PostEmbed: FC<Props> = (
{ ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError },
) => {
const dialogue = useDialogue ()
const behaviourSettings = useClientBehaviourSettings ()
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
const [manualLoadRequested, setManualLoadRequested] = useState (false)
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
@@ -105,6 +110,10 @@ const PostEmbed: FC<Props> = ({
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => {
setManualLoadRequested (false)
}, [embedAutoLoadMode, post.url])
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
@@ -117,6 +126,28 @@ const PostEmbed: FC<Props> = ({
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url)
const shouldLoadEmbed =
embedAutoLoadMode === 'auto'
|| (
embedAutoLoadMode === 'manual'
&& manualLoadRequested
)
const externalLink = (
<a href={post.url} target="_blank" rel="noreferrer">
</a>)
const manualLoadControl = (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={() => setManualLoadRequested (true)}>
</Button>
{externalLink}
</div>)
switch (url.hostname.split ('.').slice (-2).join ('.'))
{
@@ -128,6 +159,11 @@ const PostEmbed: FC<Props> = ({
const [videoId] = mVideoId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<NicoViewer
ref={ref}
@@ -150,6 +186,11 @@ const PostEmbed: FC<Props> = ({
const [userId] = mUserId
const [statusId] = mStatusId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return <TwitterEmbed userId={userId} statusId={statusId}/>
}
@@ -159,6 +200,11 @@ const PostEmbed: FC<Props> = ({
if (!(videoId))
break
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return (
<YoutubeEmbed videoId={videoId} opts={{ playerVars: {
playsinline: 1,
@@ -184,6 +230,11 @@ const PostEmbed: FC<Props> = ({
height={360}/>)
: (
<div>
{embedAutoLoadMode === 'off'
? externalLink
: embedAutoLoadMode === 'manual' && shouldLoadEmbed === false
? manualLoadControl
: (
<a href="#" onClick={async e => {
e.preventDefault ()
@@ -197,7 +248,7 @@ const PostEmbed: FC<Props> = ({
confirmText: '表示' }))
}}>
</a>
</a>)}
</div>)}
</>)
}
+6 -3
ファイルの表示
@@ -68,9 +68,12 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token, nico: '0' } })
setSuggestions (data.filter (t => t.postCount > 0))
setSuggestionsVsbl (suggestions.length > 0)
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q: token,
nico: '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
}
return (
+35 -10
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -16,6 +17,15 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => {
const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
const animationMode = behaviourSettings.animation ?? 'normal'
const cardLayoutTransition =
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
@@ -39,7 +49,8 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}}>
<motion.div
ref={cardRef}
layoutId={layoutId}
layout={animationMode === 'off' ? false : true}
layoutId={animationMode === 'off' ? undefined : layoutId}
className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
@@ -54,22 +65,36 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}}
onLayoutAnimationComplete={() => {
if (!(cardRef.current))
return
return
cardRef.current.style.zIndex = ''
cardRef.current.style.position = ''
}}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={i < 12 ? 'eager' : 'lazy'}
decoding="async"
className="object-cover w-full h-full"/>
transition={{ layout: cardLayoutTransition }}>
{thumbnailMode === 'off'
? (
<div className="flex h-full w-full items-center justify-center
bg-muted text-center text-xs text-muted-foreground">
</div>)
: (
<img
src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={
thumbnailMode === 'light'
? 'lazy'
: i < 12
? 'eager'
: 'lazy'
}
decoding="async"
className="object-cover w-full h-full"/>)}
</motion.div>
</PrefetchLink>)
})}
</div>)
}
export default PostList
export default PostList
+6 -3
ファイルの表示
@@ -146,10 +146,13 @@ const DropSlot = ({ cat }: { cat: Category }) => {
}
type Props = { post: Post; sp?: boolean }
type Props = {
className?: string
post: Post
sp?: boolean }
const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
sp = Boolean (sp)
const qc = useQueryClient ()
@@ -278,7 +281,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
}, [baseTags])
return (
<SidebarComponent>
<SidebarComponent sidebarKey="post-detail-tags" className={className}>
<TagSearch/>
<DndContext
sensors={sensors}
+18 -12
ファイルの表示
@@ -1,9 +1,8 @@
import PrefetchLink from '@/components/PrefetchLink'
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { cn } from '@/lib/utils'
import type { ComponentProps, FC, HTMLAttributes } from 'react'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types'
@@ -36,15 +35,16 @@ const TagLink: FC<Props> = ({ tag,
withWiki = true,
withCount = true,
className,
style,
title,
...props }) => {
const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
const linkClass = cn (
spanClass,
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
const colourStyle = {
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
...style,
} as CSSProperties
const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const rootClass =
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
@@ -63,7 +63,8 @@ const TagLink: FC<Props> = ({ tag,
? (
<PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)
: (
@@ -71,13 +72,15 @@ const TagLink: FC<Props> = ({ tag,
? (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/tags/${ tag.id }/deerjikists`}
className={linkClass}>
className={linkClass}
style={colourStyle}>
?
</PrefetchLink>)))
: (
@@ -120,6 +123,7 @@ const TagLink: FC<Props> = ({ tag,
<span
title={textTitle}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.matchedAlias}
@@ -134,6 +138,7 @@ const TagLink: FC<Props> = ({ tag,
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
title={textTitle}
className={cn (linkClass, textClass, className)}
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.name}
@@ -144,6 +149,7 @@ const TagLink: FC<Props> = ({ tag,
<span
title={textTitle}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.name}
+1
ファイルの表示
@@ -105,6 +105,7 @@ const TagSearch: FC = () => {
return (
<div className="relative w-full">
<input type="text"
data-shortcut-focus-search="true"
placeholder="タグ検索..."
value={search}
onChange={whenChanged}
+1 -1
ファイルの表示
@@ -96,7 +96,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</>)
return (
<SidebarComponent>
<SidebarComponent sidebarKey="posts-index-tags">
<TagSearch/>
<div className="hidden md:block mt-4">
+252 -138
ファイルの表示
@@ -8,6 +8,7 @@ import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils'
@@ -91,6 +92,10 @@ export const menuOutline = (
const TopNav: FC<Props> = ({ user }) => {
const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const reducedAnimations = animationMode === 'reduced'
const dirRef = useRef<(-1) | 1> (1)
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
@@ -155,6 +160,20 @@ const TopNav: FC<Props> = ({ user }) => {
const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
const topNavTransition =
reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const opacityTransition =
reducedAnimations
? { duration: .08 }
: { duration: .12 }
const highlightTransitionClass =
animationsOff
? undefined
: reducedAnimations
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out'
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -206,7 +225,7 @@ const TopNav: FC<Props> = ({ user }) => {
<div aria-hidden
className={cn ('absolute inset-y-0 h-12',
'bg-yellow-200 dark:bg-red-950',
'transition-[transform,width] duration-200 ease-out')}
highlightTransitionClass)}
style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/>
@@ -214,10 +233,12 @@ const TopNav: FC<Props> = ({ user }) => {
{visibleMenu.map ((item, i) => (
<motion.div
key={item.to}
layoutId={`menu-${ item.name }`}
{...(animationsOff
? { }
: { layoutId: `menu-${ item.name }` })}
animate={{ opacity: moreVsbl ? 0 : 1 }}
transition={{ opacity: { duration: .12 },
layout: { duration: .2, ease: 'easeOut' } }}
transition={{ opacity: opacityTransition,
layout: topNavTransition }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
onMouseEnter={() => setMoreVsbl (false)}>
<PrefetchLink
@@ -249,22 +270,23 @@ const TopNav: FC<Props> = ({ user }) => {
<TopNavUser user={user}/>
<a href="#"
className="md:hidden ml-auto pr-4
<button
type="button"
className="md:hidden ml-auto border-0 bg-transparent pr-4
text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
onClick={ev => {
ev.preventDefault ()
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
{menuOpen ? '×' : 'Menu'}
</a>
</button>
</nav>
<AnimatePresence initial={false}>
<motion.div
key="submenu-shell"
layout
{...(animationsOff ? { }
: { layout: true })}
className="relative z-20 hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950"
animate={{ height: submenuHeight }}
@@ -272,7 +294,7 @@ const TopNav: FC<Props> = ({ user }) => {
if (moreVsbl)
setMoreVsbl (false)
}}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
transition={{ layout: topNavTransition }}
onAnimationComplete={() => {
measure (moreVsbl ? -1 : activeIdx)
}}>
@@ -282,12 +304,14 @@ const TopNav: FC<Props> = ({ user }) => {
<div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3">
<motion.div
transition={{ duration: .2, ease: 'easeOut' }}
{...((item.visible ?? true)
transition={topNavTransition}
{...(animationsOff
? { }
: ((item.visible ?? true)
? { layoutId: `menu-${ item.name }` }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } })}
exit: { x: 40, y: -40, opacity: 0 } }))}
className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2>
</motion.div>
@@ -298,25 +322,29 @@ const TopNav: FC<Props> = ({ user }) => {
? (
<motion.div
key={`c-${ i }-${ j }`}
transition={{ duration: .2, ease: 'easeOut' }}
{...((visibleMenu[activeIdx]?.name
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } })}>
exit: { y: -40, opacity: 0 } }))}>
{subItem.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }-${ j }`}
transition={{ duration: .2, ease: 'easeOut' }}
{...((visibleMenu[activeIdx]?.name
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } })}>
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
@@ -330,129 +358,215 @@ const TopNav: FC<Props> = ({ user }) => {
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
&& (
<div className="relative h-[40px]">
<AnimatePresence initial={false} custom={dir}>
<motion.div
key={activeIdx}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3"
initial="enter"
animate="centre"
exit="exit"
transition={{ duration: .2, ease: 'easeOut' }}>
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<motion.div
key={`c-${ i }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }`}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
className="h-full flex items-center px-3">
{item.name}
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>
{animationsOff
? (
<div className="absolute inset-0 flex items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<div key={`c-${ i }`}>
{item.component}
</div>)
: (
<div key={`l-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="h-full flex items-center px-3">
{item.name}
</PrefetchLink>
</div>)))}
</div>)
: (
<AnimatePresence initial={false} custom={dir}>
<motion.div
key={activeIdx}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3"
initial="enter"
animate="centre"
exit="exit"
transition={topNavTransition}>
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<motion.div
key={`c-${ i }`}
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }`}
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="h-full flex items-center px-3">
{item.name}
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>)}
</div>))}
</motion.div>
</AnimatePresence>
<AnimatePresence initial={false}>
{menuOpen && (
<motion.div
key="spmenu"
className={cn ('flex flex-col md:hidden',
'bg-yellow-200 dark:bg-red-975 items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto' } }}
initial="closed"
animate="open"
exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</PrefetchLink>
{animationsOff
? (
menuOpen && (
<div
className={cn ('flex flex-col md:hidden',
'bg-yellow-200 dark:bg-red-975 items-start')}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</PrefetchLink>
<AnimatePresence initial={false}>
{i === openItemIdx && (
<motion.div
key={`sp-sub-${ i }`}
className="w-full bg-yellow-50 dark:bg-red-950"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0,
opacity: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto',
opacity: 1 } }}
initial="closed"
animate="open"
exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}>
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
</Fragment>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>
{i === openItemIdx && (
<div className="w-full bg-yellow-50 dark:bg-red-950">
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</PrefetchLink>)))}
</div>)}
</Fragment>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</div>))
: (
<AnimatePresence initial={false}>
{menuOpen && (
<motion.div
key="spmenu"
className={cn ('flex flex-col md:hidden',
'bg-yellow-200 dark:bg-red-975 items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto' } }}
initial="closed"
animate="open"
exit="closed"
transition={topNavTransition}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</PrefetchLink>
<AnimatePresence initial={false}>
{i === openItemIdx && (
<motion.div
key={`sp-sub-${ i }`}
className="w-full bg-yellow-50 dark:bg-red-950"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0,
opacity: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto',
opacity: 1 } }}
initial="closed"
animate="open"
exit="closed"
transition={topNavTransition}>
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
</Fragment>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>)}
</>)
}
+26 -13
ファイルの表示
@@ -7,12 +7,15 @@ type TabProps = { name: string
init?: boolean
children: React.ReactNode }
type Props = { children: React.ReactNode }
type Props = {
children: React.ReactNode
rightAddon?: React.ReactNode
}
export const Tab = ({ children }: TabProps) => <>{children}</>
const TabGroup: FC<Props> = ({ children }) => {
const TabGroup: FC<Props> = ({ children, rightAddon }) => {
const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[]
const [current, setCurrent] = useState<number> (() => {
@@ -22,17 +25,27 @@ const TabGroup: FC<Props> = ({ children }) => {
return (
<div className="mt-4">
<div className="flex gap-4">
{tabs.map ((tab, i) => (
<a key={i}
href="#"
className={cn (i === current && 'font-bold')}
onClick={ev => {
ev.preventDefault ()
setCurrent (i)
}}>
{tab.props.name}
</a>))}
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 gap-4">
{tabs.map ((tab, i) => (
<button
key={i}
type="button"
className={cn (
'bg-transparent p-0 text-left text-[hsl(var(--theme-link))] hover:opacity-85',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
i === current && 'font-bold',
)}
onClick={() => {
setCurrent (i)
}}>
{tab.props.name}
</button>))}
</div>
{rightAddon && (
<div className="shrink-0">
{rightAddon}
</div>)}
</div>
<div className="mt-2">
{tabs[current]}
+17 -1
ファイルの表示
@@ -26,7 +26,7 @@ describe ('TagInput', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏' } },
{ params: { q: '虹夏', nico: '0' } },
)
})
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
@@ -41,4 +41,20 @@ describe ('TagInput', () => {
expect (api.apiGet).not.toHaveBeenCalled ()
expect (setValue).toHaveBeenCalledWith (' ')
})
it ('sends nico=1 only when includeNico is true', async () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
render (<TagInput value="" setValue={setValue} includeNico={true}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏', nico: '1' } },
)
})
})
})
+14 -5
ファイルの表示
@@ -10,13 +10,20 @@ import type { Tag } from '@/types'
type Props = {
includeNico?: boolean
describedBy?: string
invalid?: boolean
value: string
setValue: (value: string) => void }
const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
const TagInput: FC<Props> = ({
includeNico = false,
describedBy,
invalid,
value,
setValue,
}) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
@@ -32,10 +39,12 @@ const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q,
nico: includeNico ? '1' : '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
+124
ファイルの表示
@@ -0,0 +1,124 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SidebarComponent from '@/components/layout/SidebarComponent'
const sidebarWidth = (element: Element | null): string =>
(element as HTMLElement).style.getPropertyValue ('--sidebar-width')
const sidebarRoot = (container: HTMLElement): Element =>
container.querySelector ('[style*="--sidebar-width"]')!
const sidebarTree = (props: Partial<Parameters<typeof SidebarComponent>[0]>) => (
<HelmetProvider>
<div data-sidebar-container>
<SidebarComponent sidebarKey="test-sidebar" {...props}>
Sidebar body
</SidebarComponent>
</div>
</HelmetProvider>)
const renderSidebar = (
props: Partial<Parameters<typeof SidebarComponent>[0]> = {},
containerWidth = 800,
) => {
Object.defineProperty (HTMLElement.prototype, 'clientWidth', {
configurable: true,
get () {
return this.hasAttribute ('data-sidebar-container') ? containerWidth : 0
},
})
const rendered = render (sidebarTree (props))
return rendered
}
describe ('SidebarComponent', () => {
beforeEach (() => {
Object.defineProperty (window, 'innerWidth', {
configurable: true,
value: 1_000,
})
Object.defineProperty (HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: vi.fn (),
})
Object.defineProperty (HTMLElement.prototype, 'releasePointerCapture', {
configurable: true,
value: vi.fn (),
})
Object.defineProperty (HTMLElement.prototype, 'hasPointerCapture', {
configurable: true,
value: vi.fn (() => true),
})
vi.spyOn (HTMLElement.prototype, 'setPointerCapture').mockImplementation (() => {
;
})
vi.spyOn (HTMLElement.prototype, 'releasePointerCapture').mockImplementation (() => {
;
})
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
})
it ('uses maxWidth without applying the container main-width reserve twice', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
const onWidthChange = vi.fn ()
const { container } = renderSidebar ({ maxWidth: 700, onWidthChange }, 800)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('700px')
})
expect (onWidthChange).toHaveBeenLastCalledWith (700)
})
it ('resets width and removes only its storage key on double click', async () => {
localStorage.setItem ('sidebar:test-sidebar:right:width', '420')
localStorage.setItem ('sidebar:other-sidebar:right:width', '480')
const { container } = renderSidebar ({ side: 'right' }, 900)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('420px')
})
fireEvent.doubleClick (screen.getByRole ('separator', { name: 'サイドバー幅' }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('256px')
})
expect (localStorage.getItem ('sidebar:test-sidebar:right:width')).toBeNull ()
expect (localStorage.getItem ('sidebar:other-sidebar:right:width')).toBe ('480')
})
it ('clamps current width on maxWidth changes without re-reading storage', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '420')
const { container, rerender } = renderSidebar ({ maxWidth: 420 }, 900)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('420px')
})
rerender (sidebarTree ({ maxWidth: 300 }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('300px')
})
rerender (sidebarTree ({ maxWidth: 500 }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('300px')
})
})
})
+339 -15
ファイルの表示
@@ -1,19 +1,319 @@
import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async'
import type { FC, ReactNode } from 'react'
import { cn } from '@/lib/utils'
type Props = { children: ReactNode }
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
type SidebarSide = 'left' | 'right'
type Props = {
children: ReactNode
className?: string
maxWidth?: number
onWidthChange?: (width: number) => void
sidebarKey: string
side?: SidebarSide }
type ResizeState = {
pointerId: number
startWidth: number
startX: number
side: SidebarSide }
type PendingPressState = ResizeState & { startY: number }
const DEFAULT_SIDEBAR_WIDTH = 256
const MIN_SIDEBAR_WIDTH = 192
const MIN_MAIN_WIDTH = 360
const MAX_VIEWPORT_WIDTH_RATIO = .8
const LONG_PRESS_DELAY_MS = 350
const TOUCH_MOVE_THRESHOLD_PX = 8
const SidebarComponent: FC<Props> = ({ children }) => (
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar">
<Helmet>
<style>
{`
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string =>
`sidebar:${ sidebarKey }:${ side }:width`
const getSidebarMaxWidth = (containerWidth: number): number => {
const viewportWidth =
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH)
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
}
const readStoredSidebarWidth = (
sidebarKey: string,
side: SidebarSide): number => {
if (typeof window === 'undefined')
return DEFAULT_SIDEBAR_WIDTH
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
const width = value == null ? Number.NaN : Number (value)
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
}
const SidebarComponent: FC<Props> = ({
children,
className,
maxWidth,
onWidthChange,
sidebarKey,
side = 'left',
}) => {
const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth)
const getContainerWidth = useCallback (() => {
const containerWidth = rootRef.current
?.closest ('[data-sidebar-container]')
?.clientWidth
?? window.innerWidth
return containerWidth
}, [])
const getClampedWidth = useCallback ((
nextWidth: number,
containerWidth: number): number => {
const viewportWidth =
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const maxAllowedWidth =
maxWidthRef.current == null
? getSidebarMaxWidth (containerWidth)
: Math.max (
MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
const [width, setWidth] = useState (() => (
typeof window === 'undefined'
? DEFAULT_SIDEBAR_WIDTH
: getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
window.innerWidth)))
const [resizing, setResizing] = useState (false)
const widthRef = useRef (width)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const pendingPressRef = useRef<PendingPressState | null> (null)
const resizeRef = useRef<ResizeState | null> (null)
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
const clearLongPressTimer = useCallback (() => {
if (longPressTimerRef.current == null)
return
clearTimeout (longPressTimerRef.current)
longPressTimerRef.current = null
}, [])
const restoreBodyStyle = useCallback (() => {
if (bodyStyleRef.current == null)
return
document.body.style.cursor = bodyStyleRef.current.cursor
document.body.style.userSelect = bodyStyleRef.current.userSelect
bodyStyleRef.current = null
}, [])
const startResize = useCallback ((state: ResizeState) => {
clearLongPressTimer ()
pendingPressRef.current = null
resizeRef.current = state
setResizing (true)
if (bodyStyleRef.current == null)
{
bodyStyleRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect }
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [clearLongPressTimer])
const finishResize = useCallback (() => {
clearLongPressTimer ()
pendingPressRef.current = null
const state = resizeRef.current
resizeRef.current = null
if (state != null)
window.localStorage.setItem (
storageKeyForSidebar (sidebarKey, state.side),
String (widthRef.current))
setResizing (false)
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
const applyWidth = useCallback ((nextWidth: number) => {
if (nextWidth === widthRef.current)
return
widthRef.current = nextWidth
setWidth (nextWidth)
}, [])
useEffect (() => {
maxWidthRef.current = maxWidth
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth])
const updateWidth = useCallback ((clientX: number) => {
const state = resizeRef.current
if (state == null)
return
const containerWidth = getContainerWidth ()
const delta = state.side === 'left'
? clientX - state.startX
: state.startX - clientX
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
return
if (ev.pointerType === 'mouse' && ev.button !== 0)
return
ev.preventDefault ()
ev.stopPropagation ()
ev.currentTarget.setPointerCapture (ev.pointerId)
const state = {
pointerId: ev.pointerId,
side,
startWidth: widthRef.current,
startX: ev.clientX }
if (ev.pointerType === 'mouse')
{
startResize (state)
return
}
pendingPressRef.current = { ...state, startY: ev.clientY }
clearLongPressTimer ()
longPressTimerRef.current = setTimeout (() => {
if (pendingPressRef.current != null)
startResize (pendingPressRef.current)
}, LONG_PRESS_DELAY_MS)
}
const handlePointerMove = (ev: PointerEvent<HTMLDivElement>) => {
ev.stopPropagation ()
if (resizeRef.current != null)
{
ev.preventDefault ()
updateWidth (ev.clientX)
return
}
const pendingPress = pendingPressRef.current
if (pendingPress == null || pendingPress.pointerId !== ev.pointerId)
return
const deltaX = ev.clientX - pendingPress.startX
const deltaY = ev.clientY - pendingPress.startY
const distance = Math.hypot (deltaX, deltaY)
if (distance >= TOUCH_MOVE_THRESHOLD_PX)
{
clearLongPressTimer ()
pendingPressRef.current = null
}
}
const handlePointerEnd = (ev: PointerEvent<HTMLDivElement>) => {
ev.stopPropagation ()
if (ev.currentTarget.hasPointerCapture (ev.pointerId))
ev.currentTarget.releasePointerCapture (ev.pointerId)
finishResize ()
}
const resetWidth = useCallback (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
applyWidth (nextWidth)
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
ev.preventDefault ()
ev.stopPropagation ()
resetWidth ()
}
useEffect (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
useEffect (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
if (typeof window === 'undefined')
return
const handleResize = () => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}
window.addEventListener ('resize', handleResize)
return () => window.removeEventListener ('resize', handleResize)
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
onWidthChange?.(width)
}, [onWidthChange, width])
useEffect (() => () => {
clearLongPressTimer ()
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle])
const style = {
'--sidebar-width': `${ width }px` } as CSSProperties
return (
<motion.div
ref={rootRef}
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
style={style}
className={cn (
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
className)}>
<Helmet>
<style>
{`
.sidebar
{
direction: rtl;
@@ -23,10 +323,34 @@ const SidebarComponent: FC<Props> = ({ children }) => (
{
direction: ltr;
}`}
</style>
</Helmet>
</style>
</Helmet>
{children}
</motion.div>)
<div className="h-full overflow-y-auto p-4 sidebar">
{children}
</div>
export default SidebarComponent
<div
role="separator"
aria-orientation="vertical"
aria-label="サイドバー幅"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick}
className={cn (
'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
'touch-none select-none',
side === 'left' ? '-right-2' : '-left-2',
'before:absolute before:top-0 before:bottom-0 before:w-px',
'before:bg-neutral-500/0 hover:before:bg-neutral-500/30',
'before:transition-colors before:duration-150',
side === 'left'
? 'before:left-1/2'
: 'before:right-1/2',
resizing && 'before:bg-neutral-500/40')}/>
</motion.div>)
}
export default SidebarComponent
+181
ファイルの表示
@@ -0,0 +1,181 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
import type { ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientThumbnailMode } from '@/lib/settings'
type Props = {
sectionClassName: string }
type SegmentedOption<T extends string> = {
value: T
label: string }
type SegmentedControlProps<T extends string> = {
value: T
options: SegmentedOption<T>[]
onChange: (value: T) => void }
type SettingBlockProps = {
title: string
description: string
children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
{ value: 'off', label: 'なし' },
{ value: 'reduced', label: '控えめ' },
{ value: 'normal', label: '通常' }]
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
{ value: 'off', label: '読み込まない' },
{ value: 'manual', label: 'クリック時' },
{ value: 'auto', label: '自動' }]
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
{ value: 'off', label: '非表示' },
{ value: 'light', label: '軽量' },
{ value: 'normal', label: '通常' }]
const SegmentedControl = <T extends string,> (
{ value, options, onChange }: SegmentedControlProps<T>,
) => {
return (
<div
role="radiogroup"
className="flex flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
{options.map (option => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
className={cn (
'min-w-0 flex-1 rounded-lg px-3 py-2 text-sm font-medium',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2',
value === option.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => onChange (option.value)}>
{option.label}
</button>))}
</div>)
}
const SettingBlock: FC<SettingBlockProps> = (
{ title, description, children },
) => (
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</div>)
const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => {
const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
const [draftSettings, setDraftSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useEffect (() => {
const current = getClientBehaviorSettings ()
setSavedSettings (current)
setDraftSettings (current)
}, [])
const hasUnsavedChanges = useMemo (
() => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings),
[draftSettings, savedSettings])
const updateDraft = <Key extends keyof ClientBehaviorSettings,> (
key: Key,
value: ClientBehaviorSettings[Key],
) => {
setDraftSettings (current => ({ ...current, [key]: value }))
}
const handleSave = () => {
const next = setClientBehaviorSettings (draftSettings)
setSavedSettings (next)
setDraftSettings (next)
toast ({ title: '動作設定を保存しました' })
}
const handleDiscard = () => {
setDraftSettings (savedSettings)
}
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={hasUnsavedChanges === false}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={hasUnsavedChanges === false}
onClick={handleSave}>
</Button>
</div>
</div>
<div className="space-y-4">
<SettingBlock
title="アニメーション"
description="画面遷移やレイアウト移動の動きを調整します。">
<SegmentedControl
value={draftSettings.animation ?? 'normal'}
options={animationOptions}
onChange={value => updateDraft ('animation', value)}/>
</SettingBlock>
<SettingBlock
title="埋め込み自動読込"
description="外部埋め込みを自動で読み込むかを切り替えます。">
<SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions}
onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock>
<SettingBlock
title="サムネイル表示"
description="投稿一覧や詳細の画像表示を軽量化します。">
<SegmentedControl
value={draftSettings.thumbnailMode ?? 'normal'}
options={thumbnailModeOptions}
onChange={value => updateDraft ('thumbnailMode', value)}/>
</SettingBlock>
</div>
</section>)
}
export default BehaviourSettingsSection
+251
ファイルの表示
@@ -0,0 +1,251 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import {
getConflictingShortcutActionIds,
formatKeyBinding,
isSpaceBinding,
keyBindingFromKeyboardEvent,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import { getEffectiveKeyBindings } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { FC } from 'react'
type Props = {
sectionClassName: string
}
const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
const {
keyboardSettings,
saveKeyboardSettings,
} = useKeyboardShortcutSettings ()
const [capturingActionId, setCapturingActionId] =
useState<ShortcutActionId | null> (null)
const [draftKeyboardSettings, setDraftKeyboardSettings] =
useState<ClientKeyboardSettings> (keyboardSettings)
useEffect (() => {
setDraftKeyboardSettings (keyboardSettings)
}, [keyboardSettings])
const draftEffectiveBindings = useMemo (
() => getEffectiveKeyBindings (draftKeyboardSettings.bindings),
[draftKeyboardSettings.bindings],
)
const draftConflictActionIds = useMemo (
() => getConflictingShortcutActionIds (draftEffectiveBindings),
[draftEffectiveBindings],
)
const hasUnsavedChanges = useMemo (
() => JSON.stringify (draftKeyboardSettings) !== JSON.stringify (keyboardSettings),
[draftKeyboardSettings, keyboardSettings],
)
useEffect (() => {
if (capturingActionId == null)
return
const onKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || event.key === 'Process')
return
event.preventDefault ()
event.stopPropagation ()
if (event.key === 'Escape')
{
setCapturingActionId (null)
return
}
if (event.key === 'Backspace' || event.key === 'Delete')
{
setDraftKeyboardSettings (current => ({
...current,
bindings: {
...(current.bindings ?? { }),
[capturingActionId]: null,
},
}))
setCapturingActionId (null)
return
}
const binding = keyBindingFromKeyboardEvent (event)
if (binding == null)
return
if (isSpaceBinding (binding))
{
toast ({ title: 'Space は今は割り当てられません.' })
return
}
setDraftKeyboardSettings (current => ({
...current,
bindings: {
...(current.bindings ?? { }),
[capturingActionId]: binding,
},
}))
setCapturingActionId (null)
}
document.addEventListener ('keydown', onKeyDown, true)
return () => document.removeEventListener ('keydown', onKeyDown, true)
}, [capturingActionId])
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
...definition,
currentBinding: draftEffectiveBindings[definition.id],
hasConflict: draftConflictActionIds.has (definition.id),
hasCustomBinding: Object.prototype.hasOwnProperty.call (
draftKeyboardSettings.bindings ?? { },
definition.id,
),
})), [
draftConflictActionIds,
draftEffectiveBindings,
draftKeyboardSettings.bindings,
])
const handleSave = () => {
saveKeyboardSettings (draftKeyboardSettings)
toast ({ title: 'キーボード設定を保存しました' })
}
const handleDiscard = () => {
setDraftKeyboardSettings (keyboardSettings)
setCapturingActionId (null)
}
return (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold"></h2>
{hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
</p>)}
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setDraftKeyboardSettings (current => ({
...current,
bindings: { },
}))
}}>
</Button>
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
onClick={handleSave}>
</Button>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={draftKeyboardSettings.enabled !== false}
onChange={event => {
setDraftKeyboardSettings (current => ({
...current,
enabled: event.target.checked,
}))
}}/>
<span></span>
</label>
{capturingActionId != null && (
<p className="text-sm text-muted-foreground">
Escape Backspace / Delete
</p>)}
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2 font-mono">
{formatKeyBinding (row.currentBinding)}
</td>
<td className="p-2">
<Button
type="button"
variant={capturingActionId === row.id ? 'default' : 'outline'}
className="h-8"
onClick={() => {
setCapturingActionId (
capturingActionId === row.id ? null : row.id,
)
}}>
{capturingActionId === row.id ? '入力待ち…' : '変更'}
</Button>
</td>
<td className="p-2">
<Button
type="button"
variant="ghost"
className="h-8"
disabled={!(row.hasCustomBinding)}
onClick={() => {
setDraftKeyboardSettings (current => {
const nextBindings = { ...(current.bindings ?? { }) }
delete nextBindings[row.id]
return {
...current,
bindings: nextBindings,
}
})
}}>
</Button>
</td>
<td className="p-2">
{row.hasConflict
? <span className="text-red-600 dark:text-red-400"></span>
: 'なし'}
</td>
</tr>))}
</tbody>
</table>
</div>
</section>)
}
export default KeyboardSettingsSection
+87
ファイルの表示
@@ -0,0 +1,87 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
formatKeyBinding,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import type {
KeyBinding,
ShortcutActionId,
ShortcutScope,
} from '@/lib/keyboardShortcuts'
import type { FC } from 'react'
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
activeScope: ShortcutScope
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
conflictActionIds: Set<ShortcutActionId>
availableActionIds: Set<ShortcutActionId>
}
const ShortcutHelpDialog: FC<Props> = (
{
open,
onOpenChange,
activeScope,
effectiveBindings,
conflictActionIds,
availableActionIds,
},
) => {
const rows = SHORTCUT_DEFINITIONS
.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope)
.filter (definition => availableActionIds.has (definition.id))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2 font-mono">
{formatKeyBinding (effectiveBindings[row.id])}
</td>
<td className="p-2">
{conflictActionIds.has (row.id)
? <span className="text-red-600 dark:text-red-400"></span>
: 'なし'}
</td>
</tr>))}
</tbody>
</table>
</div>
</DialogContent>
</Dialog>)
}
export default ShortcutHelpDialog
+97
ファイルの表示
@@ -0,0 +1,97 @@
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
}
+81 -22
ファイルの表示
@@ -10,6 +10,10 @@
{
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
@@ -29,12 +33,32 @@
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--theme-link: 221.2 83.2% 53.3%;
--tag-colour-deerjikist: #9f1239;
--tag-colour-deerjikist-hover: #b62a51;
--tag-colour-meme: #6b21a8;
--tag-colour-meme-hover: #8238bf;
--tag-colour-character: #4d7c0f;
--tag-colour-character-hover: #649326;
--tag-colour-general: #155e75;
--tag-colour-general-hover: #2c758c;
--tag-colour-material: #c2410c;
--tag-colour-material-hover: #d95823;
--tag-colour-meta: #a16207;
--tag-colour-meta-hover: #b8791e;
--tag-colour-nico: #4b5563;
--tag-colour-nico-hover: #626c7a;
}
.dark
{
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
@@ -58,16 +82,16 @@
body
{
@apply overflow-x-clip;
@apply overflow-x-clip bg-background text-foreground;
}
a
{
@apply text-blue-700 dark:text-blue-300;
color: hsl(var(--theme-link));
}
a:hover
{
@apply text-blue-500 dark:text-blue-100;
opacity: .85;
}
}
@@ -78,8 +102,8 @@
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color: hsl(var(--foreground));
background-color: hsl(var(--background));
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -87,6 +111,44 @@
-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='off'] *
{
scroll-behavior: auto !important;
}
:root[data-animation='off'] *,
:root[data-animation='off'] *::before,
:root[data-animation='off'] *::after
{
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
a
{
font-weight: 500;
@@ -97,28 +159,15 @@ body
{
margin: 0;
min-width: 320px;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
#root
{
min-height: 100dvh;
}
@media (prefers-color-scheme: light)
{
:root
{
color: #213547;
background-color: #ffffff;
}
a:hover
{
color: #747bff;
}
button
{
background-color: #f9f9f9;
}
background: hsl(var(--background));
color: hsl(var(--foreground));
}
@keyframes wiki-blink
@@ -138,6 +187,16 @@ body
position: relative;
}
.tag-link-colour
{
color: var(--tag-link-colour);
}
.tag-link-hover-colour:hover
{
color: var(--tag-link-hover-colour) !important;
}
.tag-marquee__animated
{
position: absolute;
+328
ファイルの表示
@@ -0,0 +1,328 @@
export type ShortcutScope =
| 'global'
| 'posts'
| 'postSearch'
| 'tags'
| 'settings'
| 'wiki'
| 'materials'
export type ShortcutActionId =
| 'global.openShortcutHelp'
| 'global.focusSearch'
| 'navigation.posts'
| 'navigation.tags'
| 'navigation.materials'
| 'navigation.wiki'
| 'pagination.next'
| 'pagination.previous'
| 'settings.account'
| 'settings.theme'
| 'settings.keyboard'
| 'settings.behavior'
export type KeyBinding = {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}
export type ShortcutDefinition = {
id: ShortcutActionId
label: string
scope: ShortcutScope
defaultBinding: KeyBinding | null
}
export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = {
global: '全画面',
posts: '広場',
postSearch: '広場検索',
tags: 'タグ',
settings: '設定',
wiki: 'Wiki',
materials: '素材',
}
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
{
id: 'global.openShortcutHelp',
label: 'ショートカット一覧を開く',
scope: 'global',
defaultBinding: { key: '?', shift: true },
},
{
id: 'global.focusSearch',
label: '検索欄へ移動',
scope: 'global',
defaultBinding: { key: '/' },
},
{
id: 'navigation.posts',
label: '広場へ移動',
scope: 'global',
defaultBinding: { key: 'p' },
},
{
id: 'navigation.tags',
label: 'タグへ移動',
scope: 'global',
defaultBinding: { key: 't' },
},
{
id: 'navigation.materials',
label: '素材へ移動',
scope: 'global',
defaultBinding: { key: 'm' },
},
{
id: 'navigation.wiki',
label: 'Wiki へ移動',
scope: 'global',
defaultBinding: { key: 'w' },
},
{
id: 'pagination.next',
label: '次のページへ移動',
scope: 'global',
defaultBinding: { key: 'ArrowRight' },
},
{
id: 'pagination.previous',
label: '前のページへ移動',
scope: 'global',
defaultBinding: { key: 'ArrowLeft' },
},
{
id: 'settings.account',
label: '設定: アカウント',
scope: 'settings',
defaultBinding: { key: '1' },
},
{
id: 'settings.theme',
label: '設定: テーマ',
scope: 'settings',
defaultBinding: { key: '2' },
},
{
id: 'settings.keyboard',
label: '設定: キーボード',
scope: 'settings',
defaultBinding: { key: '3' },
},
{
id: 'settings.behavior',
label: '設定: 動作',
scope: 'settings',
defaultBinding: { key: '4' },
},
]
export const SHORTCUT_DEFINITIONS_BY_ID =
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, ShortcutDefinition>> (
(definitions, definition) => {
definitions[definition.id] = definition
return definitions
},
{
'global.openShortcutHelp': SHORTCUT_DEFINITIONS[0],
'global.focusSearch': SHORTCUT_DEFINITIONS[1],
'navigation.posts': SHORTCUT_DEFINITIONS[2],
'navigation.tags': SHORTCUT_DEFINITIONS[3],
'navigation.materials': SHORTCUT_DEFINITIONS[4],
'navigation.wiki': SHORTCUT_DEFINITIONS[5],
'pagination.next': SHORTCUT_DEFINITIONS[6],
'pagination.previous': SHORTCUT_DEFINITIONS[7],
'settings.account': SHORTCUT_DEFINITIONS[8],
'settings.theme': SHORTCUT_DEFINITIONS[9],
'settings.keyboard': SHORTCUT_DEFINITIONS[10],
'settings.behavior': SHORTCUT_DEFINITIONS[11],
},
)
export const DEFAULT_KEY_BINDINGS =
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, KeyBinding | null>> (
(bindings, definition) => {
bindings[definition.id] = definition.defaultBinding
return bindings
},
{
'global.openShortcutHelp': null,
'global.focusSearch': null,
'navigation.posts': null,
'navigation.tags': null,
'navigation.materials': null,
'navigation.wiki': null,
'pagination.next': null,
'pagination.previous': null,
'settings.account': null,
'settings.theme': null,
'settings.keyboard': null,
'settings.behavior': null,
},
)
const normaliseKey = (key: string): string => {
if (key === 'Spacebar')
return ' '
if (key.length === 1 && key !== ' ')
return key.toLowerCase ()
return key
}
const isModifierKey = (key: string): boolean =>
key === 'Shift'
|| key === 'Control'
|| key === 'Alt'
|| key === 'Meta'
export const normaliseKeyBinding = (value: unknown): KeyBinding | null => {
if (!(value) || typeof value !== 'object')
return null
const binding = value as Partial<KeyBinding>
if (typeof binding.key !== 'string')
return null
const key = normaliseKey (binding.key)
if (key.length === 0 || isModifierKey (key))
return null
return {
key,
...(binding.ctrl ? { ctrl: true } : { }),
...(binding.shift ? { shift: true } : { }),
...(binding.alt ? { alt: true } : { }),
...(binding.meta ? { meta: true } : { }),
}
}
export const serialiseKeyBinding = (binding: KeyBinding | null): string | null => {
if (binding == null)
return null
const parts = [
binding.ctrl ? 'Ctrl' : null,
binding.shift ? 'Shift' : null,
binding.alt ? 'Alt' : null,
binding.meta ? 'Meta' : null,
binding.key,
].filter ((part): part is string => part != null)
return parts.join ('+')
}
const keyLabel = (key: string): string => {
switch (key)
{
case ' ':
return 'Space'
case 'ArrowLeft':
return 'ArrowLeft'
case 'ArrowRight':
return 'ArrowRight'
case 'ArrowUp':
return 'ArrowUp'
case 'ArrowDown':
return 'ArrowDown'
case 'Escape':
return 'Escape'
case 'Backspace':
return 'Backspace'
case 'Delete':
return 'Delete'
default:
return key.length === 1 ? key.toUpperCase () : key
}
}
export const formatKeyBinding = (binding: KeyBinding | null): string => {
if (binding == null)
return '未割当'
return [
binding.ctrl ? 'Ctrl' : null,
binding.shift ? 'Shift' : null,
binding.alt ? 'Alt' : null,
binding.meta ? 'Meta' : null,
keyLabel (binding.key),
].filter ((part): part is string => part != null)
.join (' + ')
}
export const keyBindingFromKeyboardEvent = (
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>,
): KeyBinding | null => {
const key = normaliseKey (event.key)
if (key.length === 0 || isModifierKey (key))
return null
return {
key,
...(event.ctrlKey ? { ctrl: true } : { }),
...(event.shiftKey ? { shift: true } : { }),
...(event.altKey ? { alt: true } : { }),
...(event.metaKey ? { meta: true } : { }),
}
}
export const isEditableEventTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof Element))
return false
return target.closest (
'input, textarea, select, [contenteditable="true"], [role="textbox"]',
) != null
}
export const getConflictingShortcutActionIds = (
bindings: Record<ShortcutActionId, KeyBinding | null>,
): Set<ShortcutActionId> => {
const grouped = new Map<string, ShortcutActionId[]> ()
Object.entries (bindings).forEach (([actionId, binding]) => {
const serialised = serialiseKeyBinding (binding)
if (serialised == null)
return
const ids = grouped.get (serialised) ?? []
ids.push (actionId as ShortcutActionId)
grouped.set (serialised, ids)
})
return new Set (
[...grouped.values ()]
.filter (actionIds => actionIds.length > 1)
.flat (),
)
}
export const resolveShortcutScope = (pathname: string): ShortcutScope => {
if (pathname.startsWith ('/posts/search'))
return 'postSearch'
if (pathname.startsWith ('/posts'))
return 'posts'
if (pathname.startsWith ('/tags'))
return 'tags'
if (pathname.startsWith ('/users/settings'))
return 'settings'
if (pathname.startsWith ('/wiki'))
return 'wiki'
if (pathname.startsWith ('/materials'))
return 'materials'
return 'global'
}
export const isSpaceBinding = (binding: KeyBinding | null): boolean =>
binding?.key === ' '
ファイル差分が大きすぎるため省略します 差分を読込み
+26
ファイルの表示
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import {
CLIENT_SETTINGS_EVENT,
getClientBehaviorSettings,
} from '@/lib/settings'
import type { ClientBehaviorSettings } from '@/lib/settings'
export const useClientBehaviourSettings = (): ClientBehaviorSettings => {
const [settings, setSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useEffect (() => {
const handleClientSettingsChange = () => {
setSettings (getClientBehaviorSettings ())
}
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
return () =>
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
}, [])
return settings
}
+296
ファイルの表示
@@ -0,0 +1,296 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
import {
DEFAULT_KEY_BINDINGS,
getConflictingShortcutActionIds,
isEditableEventTarget,
keyBindingFromKeyboardEvent,
resolveShortcutScope,
serialiseKeyBinding,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import {
getClientKeyboardSettings,
getEffectiveKeyBindings,
setClientKeyboardSettings,
} from '@/lib/settings'
import type {
KeyBinding,
ShortcutActionId,
ShortcutScope,
} from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { PropsWithChildren } from 'react'
type ShortcutHandler = () => void
type ShortcutHandlers = Partial<Record<ShortcutActionId, ShortcutHandler>>
type KeyboardShortcutsContextValue = {
activeScope: ShortcutScope
keyboardSettings: ClientKeyboardSettings
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
conflictActionIds: Set<ShortcutActionId>
saveKeyboardSettings: (settings: ClientKeyboardSettings) => void
registerHandlers: (handlers: ShortcutHandlers) => () => void
}
const KeyboardShortcutsContext =
createContext<KeyboardShortcutsContextValue | null> (null)
const isFocusableShortcutTarget = (element: HTMLElement): boolean => {
if (element.closest ('[hidden], [aria-hidden="true"]') != null)
return false
if (
(element instanceof HTMLInputElement
|| element instanceof HTMLButtonElement
|| element instanceof HTMLSelectElement
|| element instanceof HTMLTextAreaElement)
&& element.disabled
)
return false
const style = window.getComputedStyle (element)
if (
style.display === 'none'
|| style.visibility === 'hidden'
|| style.pointerEvents === 'none'
)
return false
return element.getClientRects ().length > 0
}
const focusSearchTarget = (): void => {
const targets = [
...document.querySelectorAll<HTMLElement> (
'[data-shortcut-focus-search="true"]',
),
]
const target = targets.find (isFocusableShortcutTarget)
if (target == null)
return
target.focus ()
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
target.select ()
}
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation ()
const navigate = useNavigate ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
const [registeredHandlers, setRegisteredHandlers] =
useState<Record<number, ShortcutHandlers>> ({ })
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
const registrationIdRef = useRef (0)
const activeScope = useMemo<ShortcutScope> (
() => resolveShortcutScope (location.pathname),
[location.pathname],
)
const effectiveBindings = useMemo (
() => getEffectiveKeyBindings (keyboardSettings.bindings),
[keyboardSettings.bindings],
)
const conflictActionIds = useMemo (
() => getConflictingShortcutActionIds (effectiveBindings),
[effectiveBindings],
)
const mergedRegisteredHandlers = useMemo<ShortcutHandlers> (
() => Object.values (registeredHandlers).reduce<ShortcutHandlers> (
(handlers, currentHandlers) => ({ ...handlers, ...currentHandlers }),
{ },
),
[registeredHandlers],
)
const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => {
const next = setClientKeyboardSettings (settings)
setKeyboardSettingsState (next)
}, [])
const registerHandlers = useCallback ((handlers: ShortcutHandlers) => {
const registrationId = ++registrationIdRef.current
setRegisteredHandlers (current => ({
...current,
[registrationId]: handlers,
}))
return () => {
setRegisteredHandlers (current => {
const next = { ...current }
delete next[registrationId]
return next
})
}
}, [])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
'global.openShortcutHelp': () => {
setShortcutHelpOpen (true)
},
'global.focusSearch': () => {
focusSearchTarget ()
},
'navigation.posts': () => navigate ('/posts'),
'navigation.tags': () => navigate ('/tags'),
'navigation.materials': () => navigate ('/materials'),
'navigation.wiki': () => navigate ('/wiki'),
'settings.account': () => navigate ('/users/settings?tab=account'),
'settings.theme': () => navigate ('/users/settings?tab=theme'),
'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'),
'settings.behavior': () => navigate ('/users/settings?tab=behavior'),
}),
[navigate],
)
const availableActionIds = useMemo<Set<ShortcutActionId>> (
() => new Set (
SHORTCUT_DEFINITIONS
.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope)
.filter (definition =>
builtinHandlers[definition.id] != null
|| mergedRegisteredHandlers[definition.id] != null)
.map (definition => definition.id),
),
[activeScope, builtinHandlers, mergedRegisteredHandlers],
)
useEffect (() => {
const onKeyDown = (event: KeyboardEvent) => {
if (
keyboardSettings.enabled === false
|| event.defaultPrevented
|| event.isComposing
|| event.key === 'Process'
|| event.repeat
)
return
if (isEditableEventTarget (event.target))
return
if (shortcutHelpOpen)
return
const binding = keyBindingFromKeyboardEvent (event)
if (binding == null)
return
const serialisedBinding = serialiseKeyBinding (binding)
if (serialisedBinding == null)
return
const candidates = SHORTCUT_DEFINITIONS.filter (definition => (
definition.scope === 'global'
|| definition.scope === activeScope
)).filter (definition => {
const currentBinding = effectiveBindings[definition.id]
return (
currentBinding != null
&& serialiseKeyBinding (currentBinding) === serialisedBinding
)
}).filter (definition => !(conflictActionIds.has (definition.id)))
if (candidates.length !== 1)
return
const actionId = candidates[0].id
const handler = builtinHandlers[actionId] ?? mergedRegisteredHandlers[actionId]
if (handler == null)
return
event.preventDefault ()
handler ()
}
document.addEventListener ('keydown', onKeyDown)
return () => document.removeEventListener ('keydown', onKeyDown)
}, [
activeScope,
conflictActionIds,
effectiveBindings,
builtinHandlers,
keyboardSettings.enabled,
mergedRegisteredHandlers,
shortcutHelpOpen,
])
const value = useMemo<KeyboardShortcutsContextValue> (() => ({
activeScope,
keyboardSettings,
effectiveBindings,
conflictActionIds,
saveKeyboardSettings,
registerHandlers,
}), [
activeScope,
conflictActionIds,
effectiveBindings,
keyboardSettings,
registerHandlers,
saveKeyboardSettings,
])
return (
<KeyboardShortcutsContext.Provider value={value}>
{children}
<ShortcutHelpDialog
open={shortcutHelpOpen}
onOpenChange={setShortcutHelpOpen}
activeScope={activeScope}
effectiveBindings={effectiveBindings}
conflictActionIds={conflictActionIds}
availableActionIds={availableActionIds}/>
</KeyboardShortcutsContext.Provider>)
}
export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => {
const context = useContext (KeyboardShortcutsContext)
if (context == null)
throw new Error ('KeyboardShortcutsProvider が必要です.')
return context
}
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
const { registerHandlers } = useKeyboardShortcutSettings ()
useEffect (
() => registerHandlers (handlers),
[handlers, registerHandlers],
)
}
export {
DEFAULT_KEY_BINDINGS,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
}
+3
ファイルの表示
@@ -4,6 +4,7 @@ import { HelmetProvider } from 'react-helmet-async'
import '@/index.css'
import App from '@/App'
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
const helmetContext = { }
@@ -13,6 +14,8 @@ const client = new QueryClient ({
gcTime: 30 * 60 * 1000,
retry: 1 } } })
applyClientAnimationMode (getClientAnimationMode ())
createRoot (document.getElementById ('root')!).render (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}>
+7 -9
ファイルの表示
@@ -24,6 +24,10 @@ import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
import { isQuestionHardFilteredAfterAnswers,
monthForCondition } from '@/lib/gekanatorQuestionFilters'
import { gekanatorKeys } from '@/lib/queryKeys'
import {
getClientGekanatorBackgroundMotion,
setClientGekanatorBackgroundMotion,
} from '@/lib/settings'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
@@ -185,7 +189,6 @@ const softenedAnswerWeight = .35
const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1'
const recentGamesStorageKey = 'gekanator:recent-games:v1'
const backgroundMotionStorageKey = 'gekanator:background-motion:v1'
const maxStoredRecentGames = 12
const specialOriginalMonthDayLabels: Record<string, string> = {
'1-1': '元日',
@@ -429,18 +432,13 @@ const storeRecentGameSummary = (
const loadBackgroundMotionMode = (): BackgroundMotionMode => {
const fallbackMode = 'on'
try
{
const raw = localStorage.getItem (backgroundMotionStorageKey)
if (raw === 'off' || raw === 'calm' || raw === 'on')
return raw
return fallbackMode
return getClientGekanatorBackgroundMotion ()
}
catch
{
return fallbackMode
return 'on'
}
}
@@ -3916,7 +3914,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
useEffect (() => {
try
{
localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode)
setClientGekanatorBackgroundMotion (backgroundMotionMode)
}
catch
{
+4 -2
ファイルの表示
@@ -6,9 +6,11 @@ import type { FC } from 'react'
const MaterialBasePage: FC = () => (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<MaterialSidebar/>
<Outlet/>
</div>)
export default MaterialBasePage
export default MaterialBasePage
+1 -1
ファイルの表示
@@ -79,7 +79,7 @@ const MaterialNewPage: FC = () => {
<FieldError messages={baseErrors}/>
{/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}>
<FormField label="タグ" messages={fieldErrors.tag}>
{({ describedBy, invalid }) => (
<TagInput
describedBy={describedBy}
+21 -10
ファイルの表示
@@ -15,6 +15,7 @@ import { SITE_TITLE } from '@/config'
import { isApiError } from '@/lib/api'
import { fetchPost, toggleViewedFlg } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound'
@@ -29,7 +30,17 @@ type Props = { user: User | null }
const PostDetailPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user)
const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
const animationMode = behaviourSettings.animation ?? 'normal'
const { id } = useParams ()
const heroLayoutId = animationMode === 'off' ? undefined : `page-${ id }`
const heroTransition =
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const postId = String (id ?? '')
const postKey = postsKeys.show (postId)
@@ -92,16 +103,16 @@ const PostDetailPage: FC<Props> = ({ user }) => {
: 'bg-gray-500 hover:bg-gray-600')
return (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<Helmet>
{(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)}
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
</Helmet>
<div className="hidden md:block">
{post && <TagDetailSidebar post={post}/>}
</div>
{post && <TagDetailSidebar post={post} className="hidden md:block"/>}
<MainArea className="relative">
{post
@@ -134,17 +145,19 @@ const PostDetailPage: FC<Props> = ({ user }) => {
</div>)
})}
{(post.thumbnail || post.thumbnailBase) && (
{thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && (
<motion.div
layoutId={`page-${ id }`}
layoutId={heroLayoutId}
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
overflow-hidden rounded-xl pointer-events-none z-50"
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: .2, ease: 'easeOut' }}>
transition={heroTransition}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={thumbnailMode === 'light' ? 'lazy' : 'eager'}
decoding="async"
className="object-cover w-full h-full"/>
</motion.div>)}
@@ -179,9 +192,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
: 'Loading...'}
</MainArea>
<div className="md:hidden">
{post && <TagDetailSidebar post={post} sp/>}
</div>
{post && <TagDetailSidebar post={post} sp className="md:hidden"/>}
</div>)
}
+75 -5
ファイルの表示
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink'
@@ -13,12 +13,24 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
getClientListLimit,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchWikiPageByTitle } from '@/lib/wiki'
import type { FC } from 'react'
import type { WikiPage } from '@/types'
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
}
const PostListPage: FC = () => {
const containerRef = useRef<HTMLDivElement | null> (null)
@@ -26,6 +38,7 @@ const PostListPage: FC = () => {
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
const location = useLocation ()
const navigate = useNavigate ()
const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? ''
const anyFlg = query.get ('match') === 'any'
@@ -33,13 +46,18 @@ const PostListPage: FC = () => {
const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery])
const tagsKey = tags.join (' ')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postList')
?? DEFAULT_POST_LIST_LIMIT
)
const order = 'original_created_at:desc' as const
const keys = {
tags: tagsKey, match, page, limit,
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
order } as const
const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
@@ -47,6 +65,40 @@ const PostListPage: FC = () => {
const cursor = ''
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('postList', { limit })
}, [limit])
const updatePage = (nextPage: number) => {
const params = new URLSearchParams (location.search)
params.set ('page', String (Math.max (1, nextPage)))
navigate (`${ location.pathname }?${ params.toString () }`)
}
const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => {
const params = new URLSearchParams (location.search)
params.set ('limit', String (next.limit ?? limit))
params.set ('page', '1')
navigate (`${ location.pathname }?${ params.toString () }`)
}
useKeyboardShortcuts (useMemo (() => ({
...(page > 1
? {
'pagination.previous': () => {
updatePage (page - 1)
},
}
: { }),
...(totalPages > 0 && page < totalPages
? {
'pagination.next': () => {
updatePage (Math.min (page + 1, totalPages))
},
}
: { }),
}), [page, totalPages, location.search]))
useLayoutEffect (() => {
scroll (0, 0)
@@ -70,6 +122,7 @@ const PostListPage: FC = () => {
return (
<div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
ref={containerRef}>
<Helmet>
@@ -89,7 +142,24 @@ const PostListPage: FC = () => {
}}/>
<MainArea>
<TabGroup>
<TabGroup
rightAddon={
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground"
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>
</label>
}>
<Tab name="広場">
{posts.length > 0
? (
+70 -80
ファイルの表示
@@ -41,16 +41,13 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('')
const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
const [title, setTitle] = useState ('')
const [titleAutoFlg, setTitleAutoFlg] = useState (true)
const [titleLoading, setTitleLoading] = useState (false)
const [url, setURL] = useState ('')
const previousURLRef = useRef ('')
const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
@@ -86,23 +83,17 @@ const PostNewPage: FC<Props> = ({ user }) => {
}
}
const handleURLBlur = () => {
if (!(url) || url === previousURLRef.current)
return
if (titleAutoFlg)
fetchTitle ()
if (thumbnailAutoFlg)
fetchThumbnail ()
previousURLRef.current = url
}
const fetchTitle = useCallback (async () => {
setTitle ('')
setTitleLoading (true)
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
setTitleLoading (false)
try
{
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '')
}
finally
{
setTitleLoading (false)
}
}, [url])
const fetchThumbnail = useCallback (async () => {
@@ -111,30 +102,26 @@ const PostNewPage: FC<Props> = ({ user }) => {
setThumbnailLoading (true)
if (thumbnailPreviewRef.current)
URL.revokeObjectURL (thumbnailPreviewRef.current)
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
setThumbnailLoading (false)
try
{
const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data)
setThumbnailPreview (imageURL)
setThumbnailFile (new File ([data],
'thumbnail.png',
{ type: data.type || 'image/png' }))
}
finally
{
setThumbnailLoading (false)
}
}, [url])
useEffect (() => {
thumbnailPreviewRef.current = thumbnailPreview
}, [thumbnailPreview])
useEffect (() => {
if (titleAutoFlg && url)
fetchTitle ()
}, [fetchTitle, titleAutoFlg, url])
useEffect (() => {
if (thumbnailAutoFlg && url)
fetchThumbnail ()
}, [fetchThumbnail, thumbnailAutoFlg, url])
if (!(editable))
return <Forbidden/>
@@ -156,59 +143,62 @@ const PostNewPage: FC<Props> = ({ user }) => {
onChange={e => setURL (e.target.value)}
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
onBlur={handleURLBlur}/>)}
className={inputClass (invalid)}/>)}
</FormField>
{/* タイトル */}
<FormField
checkBox={{
label: '自動',
checked: titleAutoFlg,
onChange: ev => setTitleAutoFlg (ev.target.checked)}}
label="タイトル"
messages={fieldErrors.title}>
<FormField label="タイトル" messages={fieldErrors.title}>
{({ describedBy, invalid }) => (
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleAutoFlg}/>)}
<div className="space-y-2">
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={title}
placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchTitle ()}
disabled={!(url) || titleLoading}>
</Button>
</div>
</div>)}
</FormField>
{/* サムネール */}
<FormField
checkBox={{
label: '自動',
checked: thumbnailAutoFlg,
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}
label="サムネール"
messages={fieldErrors.thumbnail}>
<FormField label="サムネール" messages={fieldErrors.thumbnail}>
{({ describedBy, invalid }) => (
<>
{thumbnailAutoFlg
? (thumbnailLoading
? <p className="text-gray-500 text-sm">Loading...</p>
: !(thumbnailPreview) && (
<p className="text-gray-500 text-sm">
URL
</p>))
: (
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>)}
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
<span> URL </span>
<Button
type="button"
variant="outline"
onClick={() => void fetchThumbnail ()}
disabled={!(url) || thumbnailLoading}>
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<input type="file"
accept="image/*"
aria-describedby={describedBy}
aria-invalid={invalid}
onChange={e => {
const file = e.target.files?.[0]
if (file)
{
setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file))
}
}}/>
{thumbnailPreview && (
<img src={thumbnailPreview}
alt="preview"
+104 -4
ファイルの表示
@@ -16,6 +16,15 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react'
@@ -31,6 +40,31 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
qs.set (k, t)
}
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
}
const parseOrder = (value: string | null): FetchPostsOrder | null => {
switch (value)
{
case 'title:asc':
case 'title:desc':
case 'url:asc':
case 'url:desc':
case 'original_created_at:asc':
case 'original_created_at:desc':
case 'created_at:asc':
case 'created_at:desc':
case 'updated_at:asc':
case 'updated_at:desc':
return value
default:
return null
}
}
const PostSearchPage: FC = () => {
const location = useLocation ()
@@ -41,7 +75,11 @@ const PostSearchPage: FC = () => {
[location.search])
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postSearch')
?? DEFAULT_POST_LIST_LIMIT
)
const qURL = query.get ('url') ?? ''
const qTitle = query.get ('title') ?? ''
@@ -53,7 +91,11 @@ const PostSearchPage: FC = () => {
const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchPostsOrder> ('postSearch')
?? DEFAULT_POST_LIST_ORDER
)
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null)
@@ -83,6 +125,10 @@ const PostSearchPage: FC = () => {
const results = data?.posts ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('postSearch', { limit, order })
}, [limit, order])
useEffect (() => {
setURL (qURL ?? '')
setTitle (qTitle ?? '')
@@ -113,6 +159,22 @@ const PostSearchPage: FC = () => {
qs.set ('match', matchType)
qs.set ('page', '1')
qs.set ('order', order)
qs.set ('limit', String (limit))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updateListQuery = (next: { limit?: 20 | 50 | 100
order?: FetchPostsOrder }) => {
const qs = new URLSearchParams (location.search)
qs.set ('limit', String (next.limit ?? limit))
qs.set ('order', next.order ?? order)
qs.set ('page', '1')
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updatePage = (nextPage: number) => {
const qs = new URLSearchParams (location.search)
qs.set ('page', String (Math.max (1, nextPage)))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
@@ -121,6 +183,23 @@ const PostSearchPage: FC = () => {
search ()
}
useKeyboardShortcuts (useMemo (() => ({
...(page > 1
? {
'pagination.previous': () => {
updatePage (page - 1)
},
}
: { }),
...(totalPages > 0 && page < totalPages
? {
'pagination.next': () => {
updatePage (Math.min (page + 1, totalPages))
},
}
: { }),
}), [page, totalPages, location.search]))
const defaultDirection = { title: 'asc',
url: 'asc',
original_created_at: 'desc',
@@ -134,7 +213,24 @@ const PostSearchPage: FC = () => {
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<select
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground"
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}>
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>
</label>
</div>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
@@ -142,6 +238,7 @@ const PostSearchPage: FC = () => {
{({ invalid }) => (
<input
type="text"
data-shortcut-focus-search="true"
value={title}
onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)}
@@ -161,6 +258,7 @@ const PostSearchPage: FC = () => {
<FormField label="タグ">
{() => (
<TagInput
includeNico={true}
value={tagsStr}
setValue={setTagsStr}/>)}
</FormField>
@@ -296,7 +394,9 @@ const PostSearchPage: FC = () => {
</thead>
<tbody>
{results.map (row => (
<tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<tr
key={row.id}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
<motion.div
+100 -3
ファイルの表示
@@ -14,6 +14,15 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { tagsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_TAG_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils'
@@ -35,6 +44,31 @@ const boolFromQuery = (value: string | null): boolean =>
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const n = Number (value)
return n === 20 || n === 50 || n === 100 ? n : null
}
const parseOrder = (value: string | null): FetchTagsOrder | null => {
switch (value)
{
case 'name:asc':
case 'name:desc':
case 'category:asc':
case 'category:desc':
case 'post_count:asc':
case 'post_count:desc':
case 'created_at:asc':
case 'created_at:desc':
case 'updated_at:asc':
case 'updated_at:desc':
return value
default:
return null
}
}
const TagListPage: FC = () => {
const location = useLocation ()
@@ -44,7 +78,11 @@ const TagListPage: FC = () => {
const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('tagList')
?? DEFAULT_POST_LIST_LIMIT
)
const qName = query.get ('name') ?? ''
const qCategory = (query.get ('category') || null) as Category | null
@@ -58,7 +96,11 @@ const TagListPage: FC = () => {
const qDeprecated = query.has ('deprecated')
? boolFromQuery (query.get ('deprecated'))
: null
const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchTagsOrder> ('tagList')
?? DEFAULT_TAG_LIST_ORDER
)
const [name, setName] = useState ('')
const [category, setCategory] = useState<Category | null> (null)
@@ -88,6 +130,10 @@ const TagListPage: FC = () => {
const results = data?.tags ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('tagList', { limit, order })
}, [limit, order])
useEffect (() => {
setName (qName)
setCategory (qCategory)
@@ -121,16 +167,49 @@ const TagListPage: FC = () => {
qs.set ('deprecated', deprecated ? '1' : '0')
qs.set ('page', '1')
qs.set ('order', order)
qs.set ('limit', String (limit))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updateListQuery = (next: { limit?: 20 | 50 | 100
order?: FetchTagsOrder }) => {
const qs = new URLSearchParams (location.search)
qs.set ('limit', String (next.limit ?? limit))
qs.set ('order', next.order ?? order)
qs.set ('page', '1')
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const updatePage = (nextPage: number) => {
const qs = new URLSearchParams (location.search)
qs.set ('page', String (Math.max (1, nextPage)))
navigate (`${ location.pathname }?${ qs.toString () }`)
}
const defaultDirection = { name: 'asc',
category: 'asc',
post_count: 'desc',
created_at: 'desc',
updated_at: 'desc' } as const
useKeyboardShortcuts (useMemo (() => ({
...(page > 1
? {
'pagination.previous': () => {
updatePage (page - 1)
},
}
: { }),
...(totalPages > 0 && page < totalPages
? {
'pagination.next': () => {
updatePage (Math.min (page + 1, totalPages))
},
}
: { }),
}), [page, totalPages, location.search]))
return (
<MainArea>
<Helmet>
@@ -138,7 +217,24 @@ const TagListPage: FC = () => {
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<span></span>
<select
value={limit}
onChange={ev => updateListQuery ({
limit: Number (ev.target.value) as 20 | 50 | 100,
})}
className="rounded border border-border bg-background px-2 py-1 text-sm
text-foreground">
{LIST_LIMIT_OPTIONS.map (value => (
<option key={value} value={value}>
{value}
</option>))}
</select>
</label>
</div>
<form onSubmit={handleSearch} className="space-y-2">
{/* 名前 */}
@@ -146,6 +242,7 @@ const TagListPage: FC = () => {
{({ invalid }) => (
<input
type="text"
data-shortcut-focus-search="true"
value={name}
onChange={e => setName (e.target.value)}
className={inputClass (invalid)}/>)}
+38 -30
ファイルの表示
@@ -105,6 +105,20 @@ const tagSection = (): HTMLElement =>
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
const mockDefaultApi = () => {
let commentsFetched = false
let currentTheatreInfo = buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers,
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: watchingUsers.length,
voted: false,
},
})
api.apiGet.mockImplementation ((path: string) => {
switch (path)
{
@@ -112,13 +126,17 @@ const mockDefaultApi = () => {
return Promise.resolve (theatre)
case '/theatres/7/comments':
if (commentsFetched)
return Promise.resolve ([])
commentsFetched = true
return Promise.resolve ([
buildTheatreComment ({
theatreId: 7,
no: 2,
user: { id: 1, name: 'tester' },
content: '視聴コメント',
}),
buildTheatreComment ({
theatreId: 7,
no: 2,
user: { id: 1, name: 'tester' },
content: '視聴コメント',
}),
])
case '/theatres/7/programmes':
@@ -136,32 +154,22 @@ const mockDefaultApi = () => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers,
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: watchingUsers.length,
voted: false,
},
}))
return Promise.resolve (currentTheatreInfo)
case '/theatres/7/skip_vote':
return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000,
watchingUsers,
skipVote: {
votesCount: 1,
requiredCount: 2,
watchingUsersCount: watchingUsers.length,
voted: true,
},
}))
currentTheatreInfo = buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000,
watchingUsers,
skipVote: {
votesCount: 1,
requiredCount: 2,
watchingUsersCount: watchingUsers.length,
voted: true,
},
})
return Promise.resolve (currentTheatreInfo)
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
+85 -44
ファイルの表示
@@ -10,11 +10,18 @@ import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import FieldError from '@/components/common/FieldError'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { fetchPost } from '@/lib/posts'
import {
getClientTheatreLayoutMode,
getClientTheatreTagFlow,
setClientTheatreLayoutMode,
setClientTheatreTagFlow,
} from '@/lib/settings'
import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -52,6 +59,8 @@ const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const MIN_MAIN_WIDTH = 360
const SIDEBAR_GAP_WIDTH = 16
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列',
@@ -211,11 +220,16 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const commentsRef = useRef<HTMLDivElement> (null)
const embedRef = useRef<NiconicoViewerHandle> (null)
const loadingRef = useRef (false)
const sidebarContainerRef = useRef<HTMLDivElement> (null)
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
const theatreInfoReceivedAtRef = useRef (performance.now ())
const videoLengthRef = useRef (0)
const lastCommentNoRef = useRef (0)
const [leftSidebarWidth, setLeftSidebarWidth] = useState (256)
const [rightSidebarWidth, setRightSidebarWidth] = useState (256)
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (
() => typeof window === 'undefined' ? 0 : window.innerWidth)
const [comments, setComments] = useState<TheatreComment[]> ([])
const [content, setContent] = useState ('')
const [editingPost, setEditingPost] = useState<Post | null> (null)
@@ -228,36 +242,25 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0)
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => {
const stored = localStorage.getItem (LAYOUT_STORAGE_KEY)
return (
((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[])
.includes (stored as TheatreLayoutMode))
? (stored as TheatreLayoutMode)
: 'threeColumns')
})
const [tagFlow, setTagFlow] = useState<TagFlow> (() => {
const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY)
return (
(['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
? (stored as TagFlow)
: 'vertical')
})
const [layoutMode, setLayoutMode] =
useState<TheatreLayoutMode> (() => getClientTheatreLayoutMode ())
const [tagFlow, setTagFlow] = useState<TagFlow> (() => getClientTheatreTagFlow ())
const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> ()
const changeLayoutMode = (mode: TheatreLayoutMode) => {
setLayoutMode (mode)
localStorage.setItem (LAYOUT_STORAGE_KEY, mode)
setClientTheatreLayoutMode (mode)
}
const changeTagFlow = (flow: TagFlow) => {
setTagFlow (flow)
localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow)
setClientTheatreTagFlow (flow)
}
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
theatreInfoReceivedAtRef.current = performance.now ()
theatreInfoRef.current = nextInfo
setTheatreInfo (nextInfo)
}, [])
@@ -324,6 +327,17 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
lastCommentNoRef.current = comments[0]?.no ?? 0
}, [comments])
useEffect (() => {
const updateSidebarContainerWidth = () => {
const nextWidth = sidebarContainerRef.current?.clientWidth ?? window.innerWidth
setSidebarContainerWidth (nextWidth)
}
updateSidebarContainerWidth ()
window.addEventListener ('resize', updateSidebarContainerWidth)
return () => window.removeEventListener ('resize', updateSidebarContainerWidth)
}, [])
useEffect (() => {
if (!(id))
return
@@ -387,6 +401,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
setComments (prev => [...newComments, ...prev])
}
if (loadingRef.current)
return
const currentInfo = theatreInfoRef.current
const ended =
currentInfo.hostFlg
@@ -408,8 +425,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return
}
const watchingRequestedAt = performance.now ()
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
if (!(cancelled))
if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current)
applyTheatreInfo (nextInfo)
}
catch (error)
@@ -538,6 +556,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (!(id) || !(post))
return
loadingRef.current = true
setLoading (true)
try
{
@@ -566,6 +585,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
}
finally
{
loadingRef.current = false
setLoading (false)
}
}
@@ -639,6 +659,26 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (status >= 400)
return <ErrorScreen status={status}/>
const singleSidebarMaxWidth = Math.max (
192,
sidebarContainerWidth - MIN_MAIN_WIDTH - SIDEBAR_GAP_WIDTH)
const leftSidebarMaxWidth = layoutMode === 'threeColumns'
? Math.max (
192,
sidebarContainerWidth
- rightSidebarWidth
- MIN_MAIN_WIDTH
- SIDEBAR_GAP_WIDTH * 2)
: singleSidebarMaxWidth
const rightSidebarMaxWidth = layoutMode === 'threeColumns'
? Math.max (
192,
sidebarContainerWidth
- leftSidebarWidth
- MIN_MAIN_WIDTH
- SIDEBAR_GAP_WIDTH * 2)
: singleSidebarMaxWidth
const tagPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3">
@@ -806,30 +846,26 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
</Helmet>
<div className={cn (
'grid min-h-full gap-4 overflow-visible md:h-full md:overflow-hidden',
(layoutMode === 'threeColumns'
&& ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]',
'xl:grid-cols-[18rem_minmax(0,1fr)_24rem]']),
(layoutMode === 'tagsBottom'
&& 'md:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]'),
(layoutMode === 'commentsBottom'
&& 'md:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]'))}>
<div
ref={sidebarContainerRef}
data-sidebar-container
className="flex min-h-full flex-col gap-4 overflow-visible md:h-full md:flex-row
md:overflow-hidden">
{layoutMode !== 'tagsBottom' && (
<motion.aside
layout="position"
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto
md:[direction:rtl]">
<div className="md:[direction:ltr]">
{tagPanel}
</div>
</motion.aside>)}
<SidebarComponent
sidebarKey="theatre-tags"
side="left"
maxWidth={leftSidebarMaxWidth}
onWidthChange={setLeftSidebarWidth}
className="hidden md:block">
{tagPanel}
</SidebarComponent>)}
<motion.main
layout="position"
className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
<div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
className="order-1 min-w-0 flex-1 space-y-4 md:order-none
md:min-w-[360px] md:overflow-y-auto">
<div className="space-y-4">
<section className="overflow-hidden rounded border-zinc-300
dark:border-zinc-800">
<div className="flex flex-wrap items-center justify-between gap-3
@@ -965,12 +1001,17 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</motion.main>
{layoutMode !== 'commentsBottom' && (
<motion.aside
layout="position"
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto">
{commentsPanel}
{participantsPanel}
</motion.aside>)}
<SidebarComponent
sidebarKey="theatre-comments"
side="right"
maxWidth={rightSidebarMaxWidth}
onWidthChange={setRightSidebarWidth}
className="hidden md:block">
<div className="space-y-4">
{commentsPanel}
{participantsPanel}
</div>
</SidebarComponent>)}
</div>
</motion.div>)
}
+695 -71
ファイルの表示
@@ -1,43 +1,461 @@
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import FieldError from '@/components/common/FieldError'
import Form from '@/components/common/Form'
import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink'
import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue'
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import { applyThemeSelection,
BUILTIN_THEMES,
createCustomThemeFromBase,
defaultThemeTagColoursFor,
DEFAULT_USER_SETTINGS,
deriveUserThemeModeFromSelection,
fetchUserSettings,
hasStoredClientThemeSelection,
getClientActiveThemeId,
getClientCustomThemes,
getResolvedThemeFromSelection,
setClientAppearanceThemes,
updateUserSettings } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { User } from '@/types'
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
type Props = { user: User | null
setUser: React.Dispatch<React.SetStateAction<User | null>> }
import type { Category, Tag, User } from '@/types'
import type { ActiveThemeId,
ClientTheme,
CustomClientTheme,
ThemeTagColours,
UserSettings } from '@/lib/settings'
type Props = {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }
type UserFormField = 'name'
type SettingsFormField = 'theme'
type SettingsTab = 'account' | 'theme' | 'keyboard' | 'behavior'
type TabSpec = {
id: SettingsTab
label: string }
type SharedSectionProps = {
user: User
name: string
onNameChange: (value: string) => void
onInheritOpen: () => void
onUserCodeOpen: () => void
onUserSubmit: () => void
userBaseErrors: string[]
userFieldErrors: Partial<Record<UserFormField, string[]>>
draftActiveThemeId: ActiveThemeId
draftCustomThemes: Record<string, CustomClientTheme>
selectedTheme: ClientTheme
draftTagColours: ThemeTagColours
onThemeChange: (themeId: ActiveThemeId) => void
onEnsureEditableTheme: () => void
onTagColourChange: (category: Category, colour: string) => void
onResetAllTagColours: () => void
onResetTagColour: (category: Category) => void
onThemeSubmit: () => void
settingsBaseErrors: string[]
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>> }
const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' },
{ id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' },
{ id: 'behavior', label: '動作' } ]
const sectionClassName =
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
const themeLabel: Record<UserSettings['theme'], string> = {
system: 'システム設定に従う',
light: 'ライト・モード',
dark: 'ダーク・モード' }
const builtinThemeLabel: Record<'light' | 'dark', string> = {
light: 'ライト・モード',
dark: 'ダーク・モード' }
const previewTag = (
id: number,
name: string,
category: Category,
postCount: number,
hasWiki: boolean,
options?: { hasDeerjikists?: boolean },
): Tag => ({
id,
name,
category,
deprecatedAt: null,
aliases: [],
parents: [],
postCount,
createdAt: '',
updatedAt: '',
hasWiki,
materialId: null,
hasDeerjikists: options?.hasDeerjikists ?? false,
children: [] })
const previewTags: Record<Category, Tag> = {
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
meme: previewTag (2, 'ミーム', 'meme', 34, true),
character: previewTag (3, 'キャラクター', 'character', 56, false),
general: previewTag (4, '一般', 'general', 78, true),
material: previewTag (5, '素材', 'material', 90, false),
meta: previewTag (6, 'メタ', 'meta', 21, true),
nico: previewTag (7, 'nico:sm9', 'nico', 43, false) }
const parseTab = (search: string): SettingsTab => {
const tab = new URLSearchParams (search).get ('tab')
return (tabs.some (candidate => candidate.id === tab)
? (tab as SettingsTab)
: 'account')
}
const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }`
const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }`
const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
Object.fromEntries (
CATEGORIES.flatMap (category => [
[`--tag-colour-${ category }`, tagColours[category]],
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
const themeLabelById = (
themeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): string => {
if (themeId === 'system')
return themeLabel.system
if (themeId === 'light' || themeId === 'dark')
return builtinThemeLabel[themeId]
return (customThemes[themeId] ?? BUILTIN_THEMES.light).name
}
const AccountSection: FC<SharedSectionProps> = (
{ name,
onInheritOpen,
onNameChange,
onUserCodeOpen,
onUserSubmit,
user,
userBaseErrors,
userFieldErrors },
) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={userBaseErrors}/>
<FormField label="表示名" messages={userFieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input
type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder="名もなきニジラー"
onChange={ev => onNameChange (ev.target.value)}/>
{!(user.name) && (
<p className="mt-1 text-sm text-red-500">
30 !!!!
</p>)}
</>)}
</FormField>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={onUserSubmit}>
</Button>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={onUserCodeOpen}
className="bg-gray-600 text-white"
disabled={!(user)}>
</Button>
<Button
type="button"
onClick={onInheritOpen}
className="bg-red-600 text-white"
disabled={!(user)}>
</Button>
</div>
</div>
</section>)
const ThemeSection: FC<SharedSectionProps> = (
{ draftActiveThemeId,
draftCustomThemes,
selectedTheme,
draftTagColours,
onEnsureEditableTheme,
onResetAllTagColours,
onResetTagColour,
onTagColourChange,
onThemeChange,
onThemeSubmit,
settingsBaseErrors,
settingsFieldErrors },
) => (
<section className={sectionClassName}>
<h2 className="text-xl font-bold"></h2>
<FieldError messages={settingsBaseErrors}/>
<FormField label="基本テーマ" messages={settingsFieldErrors.theme}>
{() => (
<select
className={inputClass (Boolean (settingsFieldErrors.theme?.length))}
value={draftActiveThemeId}
onChange={ev => onThemeChange (ev.target.value as ActiveThemeId)}>
<option value="system">{themeLabel.system}</option>
<option value="light">{builtinThemeLabel.light}</option>
<option value="dark">{builtinThemeLabel.dark}</option>
{Object.values (draftCustomThemes).length > 0 && (
<optgroup label="カスタムテーマ">
{Object.values (draftCustomThemes).map (theme => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>))}
</optgroup>)}
</select>)}
</FormField>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="font-medium"></h3>
</div>
<Button
type="button"
variant="outline"
onClick={onResetAllTagColours}
disabled={selectedTheme.builtin || draftActiveThemeId === 'system'}>
</Button>
</div>
<div className="space-y-3">
{CATEGORIES.map (category => {
const resolvedDefaults =
defaultThemeTagColoursFor (selectedTheme.baseTheme)
const isDefault =
draftTagColours[category] === resolvedDefaults[category]
return (
<div
key={category}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<div style={themePreviewStyle (draftTagColours)}>
<TagLink
tag={previewTags[category]}
linkFlg={false}
truncateOnMobile
withWiki={false}/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="color"
value={draftTagColours[category]}
onPointerDown={onEnsureEditableTheme}
onFocus={onEnsureEditableTheme}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
bg-transparent p-1"/>
<Button
type="button"
variant="ghost"
className="h-auto px-0 py-0 text-xs"
onClick={() => onResetTagColour (category)}
disabled={
isDefault
|| selectedTheme.builtin
|| draftActiveThemeId === 'system'
}>
</Button>
</div>
</div>)
})}
</div>
</div>
<Button type="button" onClick={onThemeSubmit}>
</Button>
</section>)
const SettingPage: FC<Props> = ({ user, setUser }) => {
const [name, setName] = useState ('')
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const [inheritVsbl, setInheritVsbl] = useState (false)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<UserFormField> ()
const location = useLocation ()
const navigate = useNavigate ()
const handleSubmit = async () => {
const [inheritVsbl, setInheritVsbl] = useState (false)
const [loading, setLoading] = useState (true)
const [name, setName] = useState ('')
const [settingsError, setSettingsError] = useState<string | null> (null)
const [, setDraftSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
const [savedActiveThemeId, setSavedActiveThemeId] =
useState<ActiveThemeId> (getClientActiveThemeId ())
const [savedCustomThemes, setSavedCustomThemes] =
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
const [draftActiveThemeId, setDraftActiveThemeId] =
useState<ActiveThemeId> (getClientActiveThemeId ())
const [draftCustomThemes, setDraftCustomThemes] =
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
const { conflictActionIds } = useKeyboardShortcutSettings ()
const { baseErrors: userBaseErrors,
fieldErrors: userFieldErrors,
clearValidationErrors: clearUserValidationErrors,
applyValidationError: applyUserValidationError } = useValidationErrors<UserFormField> ()
const { baseErrors: settingsBaseErrors,
fieldErrors: settingsFieldErrors,
clearValidationErrors: clearSettingsValidationErrors,
applyValidationError: applySettingsValidationError } =
useValidationErrors<SettingsFormField> ()
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
const selectedTheme = useMemo<ClientTheme> (
() => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes),
[draftActiveThemeId, draftCustomThemes])
const draftTagColours = selectedTheme.tokens.tagColours
const setActiveTab = (tab: SettingsTab) => {
const params = new URLSearchParams (location.search)
params.set ('tab', tab)
navigate (
`${ location.pathname }?${ params.toString () }`,
{ replace: true })
}
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
() => (
{ account: Boolean (userFieldErrors.name?.length),
theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: conflictActionIds.size > 0,
behavior: false }),
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name])
const applyDraftThemeSelection = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
) => {
setDraftActiveThemeId (activeThemeId)
setDraftCustomThemes (customThemes)
setDraftSettings (current => ({
...current,
theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes) }))
applyThemeSelection (activeThemeId, customThemes)
}
const ensureEditableCustomTheme = (): {
activeThemeId: ActiveThemeId
customThemes: Record<string, CustomClientTheme> } => {
if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null)
{
return { activeThemeId: draftActiveThemeId,
customThemes: draftCustomThemes }
}
const customTheme = createCustomThemeFromBase (
selectedTheme.baseTheme,
{ ...selectedTheme.tokens,
tagColours: { ...selectedTheme.tokens.tagColours } })
const nextCustomThemes = {
...draftCustomThemes,
[customTheme.id]: customTheme }
return {
activeThemeId: customTheme.id,
customThemes: nextCustomThemes }
}
const handleEnsureEditableTheme = (): {
activeThemeId: ActiveThemeId
customThemes: Record<string, CustomClientTheme> } => {
const editable = ensureEditableCustomTheme ()
if (editable.activeThemeId !== draftActiveThemeId
|| editable.customThemes !== draftCustomThemes)
applyDraftThemeSelection (editable.activeThemeId, editable.customThemes)
return editable
}
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
const currentIndex = tabs.findIndex (tab => tab.id === activeTab)
switch (event.key)
{
case 'ArrowDown':
case 'ArrowRight':
event.preventDefault ()
setActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
break
case 'ArrowUp':
case 'ArrowLeft':
event.preventDefault ()
setActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
break
case 'Home':
event.preventDefault ()
setActiveTab (tabs[0].id)
break
case 'End':
event.preventDefault ()
setActiveTab (tabs[tabs.length - 1].id)
break
}
}
const handleUserSubmit = async () => {
if (!(user))
return
clearValidationErrors ()
clearUserValidationErrors ()
const formData = new FormData
formData.append ('name', name)
@@ -47,13 +465,35 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const data = await apiPut<User> (
`/users/${ user.id }`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } })
setUser (user => ({ ...user, ...data }))
toast ({ title: '設定を更新しました.' })
setUser (currentUser => ({ ...currentUser, ...data }))
toast ({ title: '表示名を更新しました.' })
}
catch (e)
catch (submitError)
{
applyValidationError (e)
toast ({ title: 'しっぱい……' })
applyUserValidationError (submitError)
toast ({ title: '表示名を更新できませんでした.' })
}
}
const handleThemeSubmit = async () => {
clearSettingsValidationErrors ()
try
{
const themeMode =
deriveUserThemeModeFromSelection (draftActiveThemeId, draftCustomThemes)
const data = await updateUserSettings ({ theme: themeMode })
setDraftSettings ({ ...data, theme: themeMode })
setSavedActiveThemeId (draftActiveThemeId)
setSavedCustomThemes (draftCustomThemes)
setClientAppearanceThemes (draftActiveThemeId, draftCustomThemes)
applyThemeSelection (draftActiveThemeId, draftCustomThemes)
toast ({ title: 'テーマ設定を保存しました.' })
}
catch (submitError)
{
applySettingsValidationError (submitError)
toast ({ title: 'テーマ設定を保存できませんでした.' })
}
}
@@ -64,6 +504,142 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
setName (user.name ?? '')
}, [user])
useEffect (() => {
if (!(user))
return
let cancelled = false
void (async () => {
setLoading (true)
try
{
const data = await fetchUserSettings ()
if (cancelled)
return
const customThemes = getClientCustomThemes ()
const activeThemeId = (hasStoredClientThemeSelection ()
? getClientActiveThemeId ()
: data.theme)
const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes)
setDraftSettings ({ ...data, theme: themeMode })
setSavedActiveThemeId (activeThemeId)
setSavedCustomThemes (customThemes)
setDraftActiveThemeId (activeThemeId)
setDraftCustomThemes (customThemes)
setSettingsError (null)
}
catch
{
if (cancelled)
return
setSettingsError ('設定の読込みに失敗しました.')
}
finally
{
if (!(cancelled))
setLoading (false)
}
}) ()
return () => {
cancelled = true
}
}, [user])
useEffect (() => {
if (userFieldErrors.name?.length)
setActiveTab ('account')
}, [location.pathname, location.search, userFieldErrors.name])
useEffect (() => {
if (settingsFieldErrors.theme?.length)
setActiveTab ('theme')
}, [location.pathname, location.search, settingsFieldErrors.theme])
useEffect (() => {
applyThemeSelection (draftActiveThemeId, draftCustomThemes)
return () => {
applyThemeSelection (savedActiveThemeId, savedCustomThemes)
}
}, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes])
const sectionProps: SharedSectionProps | null =
user
? { user,
name,
onNameChange: setName,
onInheritOpen: () => setInheritVsbl (true),
onUserCodeOpen: () => setUserCodeVsbl (true),
onUserSubmit: handleUserSubmit,
userBaseErrors,
userFieldErrors,
draftActiveThemeId,
draftCustomThemes,
selectedTheme,
draftTagColours,
onThemeChange: themeId => {
applyDraftThemeSelection (themeId, draftCustomThemes)
},
onEnsureEditableTheme: () => {
handleEnsureEditableTheme ()
},
onTagColourChange: (category, colour) => {
const editable = handleEnsureEditableTheme ()
const editableTheme = editable.customThemes[editable.activeThemeId]
const nextCustomThemes = {
...editable.customThemes,
[editable.activeThemeId]: {
...editableTheme,
tokens: {
...editableTheme.tokens,
tagColours: {
...editableTheme.tokens.tagColours,
[category]: colour } } } }
applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes)
},
onResetAllTagColours: () => {
if (selectedTheme.builtin || draftActiveThemeId === 'system')
return
applyDraftThemeSelection (
draftActiveThemeId,
{
...draftCustomThemes,
[draftActiveThemeId]: {
...draftCustomThemes[draftActiveThemeId],
tokens: {
...draftCustomThemes[draftActiveThemeId].tokens,
tagColours: {
...defaultThemeTagColoursFor (selectedTheme.baseTheme) } } } })
},
onResetTagColour: category => {
if (selectedTheme.builtin || draftActiveThemeId === 'system')
return
applyDraftThemeSelection (
draftActiveThemeId,
{ ...draftCustomThemes,
[draftActiveThemeId]: {
...draftCustomThemes[draftActiveThemeId],
tokens: {
...draftCustomThemes[draftActiveThemeId].tokens,
tagColours: {
...draftCustomThemes[draftActiveThemeId].tokens.tagColours,
[category]:
defaultThemeTagColoursFor (
selectedTheme.baseTheme)[category] } } } })
},
onThemeSubmit: handleThemeSubmit,
settingsBaseErrors,
settingsFieldErrors }
: null
return (
<MainArea>
<Helmet>
@@ -71,63 +647,111 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<title> | {SITE_TITLE}</title>
</Helmet>
<Form>
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
<PageTitle></PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/>
<div
role="tablist"
aria-label="設定区分"
aria-orientation="horizontal"
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
{tabs.map (tab => (
<button
key={tab.id}
id={tabButtonId (tab.id)}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={tabPanelId (tab.id)}
className={cn (
'shrink-0 rounded-full border px-3 py-2 text-sm font-medium',
(activeTab === tab.id
? ['border-slate-900 bg-slate-900 text-white',
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
: ['border-border bg-background text-foreground']))}
onClick={() => setActiveTab (tab.id)}>
{tab.label}
{settingsTabErrors[tab.id] ? ' !' : ''}
</button>))}
</div>
{loading ? 'Loading...' : (
<div
id={tabPanelId (activeTab)}
role="tabpanel"
aria-labelledby={tabButtonId (activeTab)}>
{sectionProps && activeTab === 'account' && <AccountSection {...sectionProps}/>}
{sectionProps && activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && (
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</div>)}
</div>
{user ? (
<>
<FieldError messages={baseErrors}/>
<div className="hidden md:flex md:justify-center md:px-4">
<div className="grid w-full max-w-[58rem] grid-cols-[12rem_minmax(0,1fr)] gap-6">
<div
role="tablist"
aria-label="設定区分"
aria-orientation="vertical"
className="sticky top-4 flex h-fit flex-col gap-2">
{tabs.map (tab => (
<button
key={tab.id}
id={tabButtonId (tab.id)}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={tabPanelId (tab.id)}
tabIndex={activeTab === tab.id ? 0 : -1}
className={cn (
'rounded-xl border px-4 py-3 text-left text-sm font-medium',
(activeTab === tab.id
? ['border-slate-900 bg-slate-900 text-white',
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
: ['border-slate-300 bg-white text-slate-700',
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']))}
onClick={() => setActiveTab (tab.id)}
onKeyDown={handleTabKeyDown}>
{tab.label}
{settingsTabErrors[tab.id] ? ' ⚠' : ''}
</button>))}
</div>
{/* 名前 */}
<FormField label="表示名" messages={fieldErrors.name}>
{({ describedBy, invalid }) => (
<>
<input type="text"
aria-describedby={describedBy}
aria-invalid={invalid}
className={inputClass (invalid)}
value={name}
placeholder="名もなきニジラー"
onChange={ev => setName (ev.target.value)}/>
{(user && !(user.name)) && (
<p className="mt-1 text-sm text-red-500">
30 !!!!
</p>)}
</>)}
</FormField>
<div className="w-full max-w-xl justify-self-center space-y-4">
<PageTitle></PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/>
{loading ? 'Loading...' : (
<div
id={tabPanelId (activeTab)}
role="tabpanel"
aria-labelledby={tabButtonId (activeTab)}>
{sectionProps && (
<>
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
{activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && (
<BehaviourSettingsSection sectionClassName={sectionClassName}/>)}
</>)}
</div>)}
</div>
</div>
</div>
{/* 送信 */}
<Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
</Button>
<UserCodeDialogue
visible={userCodeVsbl}
onVisibleChange={setUserCodeVsbl}
user={user}
setUser={setUser}/>
{/* 引継ぎ */}
<div>
<Label></Label>
<Button onClick={() => setUserCodeVsbl (true)}
className="px-4 py-2 bg-gray-600 text-white rounded disabled:bg-gray-400"
disabled={!(user)}>
</Button>
<Button onClick={() => setInheritVsbl (true)}
className="ml-2 px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400"
disabled={!(user)}>
</Button>
</div>
</>) : 'Loading...'}
</Form>
<UserCodeDialogue visible={userCodeVsbl}
onVisibleChange={setUserCodeVsbl}
user={user}
setUser={setUser}/>
<InheritDialogue visible={inheritVsbl}
onVisibleChange={setInheritVsbl}
setUser={setUser}/>
<InheritDialogue
visible={inheritVsbl}
onVisibleChange={setInheritVsbl}
setUser={setUser}/>
</MainArea>)
}
export default SettingPage
+6
ファイルの表示
@@ -24,6 +24,11 @@ import type { FC } from 'react'
import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
// `wiki_editor_mode` is still kept in backend settings, but the common
// settings screen does not expose it at present. Until that UI returns, the
// editor stays on the fixed split view here.
type Props = { user: User | null }
@@ -114,6 +119,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)}
</FormField>
+6
ファイルの表示
@@ -22,6 +22,11 @@ import 'react-markdown-editor-lite/lib/index.css'
import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
// `wiki_editor_mode` is still kept in backend settings, but the common
// settings screen does not expose it at present. Until that UI returns, the
// editor stays on the fixed split view here.
type Props = { user: User | null }
@@ -92,6 +97,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{() => (
<MdEditor value={body}
style={{ height: '500px' }}
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)}
</FormField>
+1
ファイルの表示
@@ -8,6 +8,7 @@ import { DARK_COLOUR_SHADE,
const colours = Object.values (TAG_COLOUR)
export default {
darkMode: 'class',
content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'],
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),