コミットを比較
14 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 40cc3b2c0c | |||
| 61ab4744bd | |||
| ca92c62049 | |||
| 4e3fb6bef7 | |||
| 3a9ac8af32 | |||
| 7bc9ee1e68 | |||
| 95b4db1fc2 | |||
| 4117bdd31e | |||
| fecbb4b354 | |||
| d0ea329887 | |||
| 3b29c3e10e | |||
| 518c5fa0f2 | |||
| 4cc2dc5441 | |||
| 6a57dc53bf |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+88
-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,15 @@ 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,
|
||||
CLIENT_SETTINGS_EVENT,
|
||||
fetchUserSettings,
|
||||
getClientAnimationMode,
|
||||
getClientThemeMode,
|
||||
seedClientThemeMode } from '@/lib/settings'
|
||||
|
||||
import type { ClientAnimationMode } from '@/lib/settings'
|
||||
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 +112,42 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
|
||||
const App: FC = () => {
|
||||
const [user, setUser] = useState<User | null> (null)
|
||||
const [status, setStatus] = useState (200)
|
||||
const [animationMode, setAnimationMode] =
|
||||
useState<ClientAnimationMode> (() => getClientAnimationMode ())
|
||||
|
||||
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 handleClientSettingsChange = () => {
|
||||
setAnimationMode (getClientAnimationMode ())
|
||||
}
|
||||
|
||||
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
|
||||
return () =>
|
||||
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
const createUser = async () => {
|
||||
@@ -137,6 +182,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 +212,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>
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -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 { getClientEmbedAutoLoadMode } from '@/lib/settings'
|
||||
|
||||
import type { FC, RefObject } from 'react'
|
||||
|
||||
@@ -24,16 +26,18 @@ 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 embedAutoLoadMode = getClientEmbedAutoLoadMode ()
|
||||
const [manualLoadRequested, setManualLoadRequested] = useState (false)
|
||||
const [framed, setFramed] = useState (false)
|
||||
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
|
||||
const niconicoVideoReadyRef = useRef (false)
|
||||
@@ -105,6 +109,10 @@ const PostEmbed: FC<Props> = ({
|
||||
niconicoVideoReadyRef.current = false
|
||||
}, [post.url])
|
||||
|
||||
useEffect (() => {
|
||||
setManualLoadRequested (false)
|
||||
}, [embedAutoLoadMode, post.url])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(youtubePlayer) || !(onPlaybackChange))
|
||||
return
|
||||
@@ -117,6 +125,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 +158,11 @@ const PostEmbed: FC<Props> = ({
|
||||
|
||||
const [videoId] = mVideoId
|
||||
|
||||
if (embedAutoLoadMode === 'off')
|
||||
return externalLink
|
||||
if (shouldLoadEmbed === false)
|
||||
return manualLoadControl
|
||||
|
||||
return (
|
||||
<NicoViewer
|
||||
ref={ref}
|
||||
@@ -150,6 +185,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 +199,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 +229,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 +247,7 @@ const PostEmbed: FC<Props> = ({
|
||||
confirmText: '表示' }))
|
||||
}}>
|
||||
外部ページを表示
|
||||
</a>
|
||||
</a>)}
|
||||
</div>)}
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { getClientThumbnailMode } from '@/lib/settings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
||||
|
||||
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
|
||||
|
||||
const PostList: FC<Props> = ({ posts, onClick }) => {
|
||||
const location = useLocation ()
|
||||
const thumbnailMode = getClientThumbnailMode ()
|
||||
|
||||
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
|
||||
|
||||
@@ -54,22 +56,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"/>
|
||||
{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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -249,16 +249,16 @@ 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}>
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 からのコピペのため,共通化を考へる.
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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 {
|
||||
ClientAnimationMode,
|
||||
ClientBehaviorSettings,
|
||||
ClientEmbedAutoLoadMode,
|
||||
ClientThumbnailMode } from '@/lib/settings'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
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: 'normal', label: '通常' },
|
||||
{ value: 'reduced', label: '控えめ' },
|
||||
{ value: 'off', label: 'なし' },
|
||||
]
|
||||
|
||||
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
|
||||
{ value: 'auto', label: '自動' },
|
||||
{ value: 'manual', label: 'クリック時' },
|
||||
{ value: 'off', label: '読み込まない' },
|
||||
]
|
||||
|
||||
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
|
||||
{ value: 'normal', label: '通常' },
|
||||
{ value: 'light', label: '軽量' },
|
||||
{ value: 'off', 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>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
この設定は現在のブラウザに保存されます。
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
重い端末や通信量を抑えたい場合に調整します。
|
||||
</p>
|
||||
{hasUnsavedChanges && (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
未保存の変更があります
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={hasUnsavedChanges === 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 === ' '
|
||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -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,
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 { getClientThumbnailMode } from '@/lib/settings'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
import { cn } from '@/lib/utils'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
@@ -29,6 +30,7 @@ type Props = { user: User | null }
|
||||
|
||||
const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
const editable = canEditContent (user)
|
||||
const thumbnailMode = getClientThumbnailMode ()
|
||||
const { id } = useParams ()
|
||||
const postId = String (id ?? '')
|
||||
const postKey = postsKeys.show (postId)
|
||||
@@ -134,7 +136,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
</div>)
|
||||
})}
|
||||
|
||||
{(post.thumbnail || post.thumbnailBase) && (
|
||||
{thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && (
|
||||
<motion.div
|
||||
layoutId={`page-${ id }`}
|
||||
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
|
||||
@@ -145,6 +147,8 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
||||
<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>)}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -90,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
|
||||
? (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}/>)}
|
||||
|
||||
@@ -16,6 +16,12 @@ 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'
|
||||
@@ -236,32 +242,20 @@ 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) => {
|
||||
|
||||
@@ -1,43 +1,478 @@
|
||||
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>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-border bg-background p-3
|
||||
text-foreground shadow-sm transition-colors">
|
||||
<p className="text-sm font-medium">プレビュー</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
現在の表示テーマ: {themeLabelById (draftActiveThemeId, draftCustomThemes)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3" style={themePreviewStyle (draftTagColours)}>
|
||||
{CATEGORIES.map (category => (
|
||||
<TagLink
|
||||
key={category}
|
||||
tag={previewTags[category]}
|
||||
linkFlg={false}
|
||||
truncateOnMobile
|
||||
withWiki={false}/>))}
|
||||
</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 +482,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 +521,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 +664,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }`),
|
||||
|
||||
新しい課題から参照
ユーザをブロックする