コミットを比較

..

1 コミット

作成者 SHA1 メッセージ 日付
みてるぞ d6670bb65e #383 2026-06-29 01:56:36 +09:00
106個のファイルの変更782行の追加9972行の削除
+19 -660
ファイルの表示
@@ -106,10 +106,6 @@ npm run preview
- Explain risks directly. - Explain risks directly.
- Prefer single quotes for strings unless interpolation or escaping makes - Prefer single quotes for strings unless interpolation or escaping makes
double quotes better. 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: never put a space before method-call parentheses.
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。 - Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
- Ruby: never put a line break immediately before `)`. - Ruby: never put a line break immediately before `)`.
@@ -204,7 +200,7 @@ const value =
## Backend rules ## Backend rules
- Inspect existing routes, controllers, models, services, and specs before - Inspect existing routes, controllers, models, services, and specs before
editing backend behaviour. editing backend behavior.
- Never run `db:drop`, `db:reset`, `db:setup`, or any command that drops or - 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 recreates the development database. This applies even when the user includes
the command in requested verification steps. the command in requested verification steps.
@@ -215,7 +211,7 @@ const value =
- Repeated destructive instructions are not enough confirmation because they - Repeated destructive instructions are not enough confirmation because they
may be auto-generated. Without `いいからやれ`, refuse or substitute a safer may be auto-generated. Without `いいからやれ`, refuse or substitute a safer
test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`. test-only command such as `RAILS_ENV=test bundle exec rails db:migrate`.
- For API behaviour changes, add or update request specs under - For API behavior changes, add or update request specs under
`backend/spec/requests` only when the user explicitly asks for tests. `backend/spec/requests` only when the user explicitly asks for tests.
- Prefer RSpec for new backend tests; existing minitest files under - Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage. `backend/test` do not make minitest the default for new coverage.
@@ -223,8 +219,8 @@ const value =
- Preserve the `X-Transfer-Code` user identification flow unless the task - Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication. explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency, - Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behaviour. wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalisation, implications, similarities, and discard behaviour. - Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
- Be sensitive to N+1 queries; avoid introducing them and proactively fix - Be sensitive to N+1 queries; avoid introducing them and proactively fix
existing N+1 issues in the code path being edited. existing N+1 issues in the code path being edited.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema. - Keep migration files and `backend/db/schema.rb` consistent when changing schema.
@@ -245,9 +241,9 @@ const value =
- Internal links must use `PrefetchLink`. - Internal links must use `PrefetchLink`.
- External links must use `<a>` with `target="_blank"`. - External links must use `<a>` with `target="_blank"`.
- When adding or changing Tailwind `bg-*` classes in TSX, pair them with an - When adding or changing Tailwind `bg-*` classes in TSX, pair them with an
explicit readable `text-*` colour and dark-mode counterparts such as explicit readable `text-*` color and dark-mode counterparts such as
`dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present. `dark:bg-*`, `dark:text-*`, and `dark:border-*` where a border is present.
- Do not rely on inherited text colour for light backgrounds. This is especially - Do not rely on inherited text color for light backgrounds. This is especially
important for chips, cards, buttons, and panels that may inherit white text in important for chips, cards, buttons, and panels that may inherit white text in
dark mode. dark mode.
- Mobile UI must be checked as a first-class layout. Avoid wide fixed content, - Mobile UI must be checked as a first-class layout. Avoid wide fixed content,
@@ -259,52 +255,15 @@ const value =
`!==` over negating a comparison like `!(a === b)`. `!==` over negating a comparison like `!(a === b)`.
- In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for - In TypeScript and TSX, prefer `++i` or `--i` over `i += 1` or `i -= 1` for
simple unit-step counter updates. simple unit-step counter updates.
- For user-facing Japanese text, follow the 1986 Cabinet Notice - For user-facing Japanese text, prefer modern kana usage and natural current
《現代仮名遣い》 and avoid historical kana spellings unless the task phrasing over historical spellings or awkward literal wording.
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 `...`. - For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
### Frontend TypeScript and TSX style ### Frontend TSX style
- The delimiter-placement and line-breaking rules in this section apply to - Preserve the local TSX formatting style.
both plain TypeScript `.ts` and TSX `.tsx`, unless a bullet explicitly says
it is JSX- or React-specific.
- Preserve the local TypeScript and TSX formatting style.
- Do not normalize TSX to common Prettier-style React formatting unless - Do not normalize TSX to common Prettier-style React formatting unless
explicitly asked. explicitly asked.
- Treat TypeScript and TSX formatting rules as hard constraints, not
preferences. Before finishing a TypeScript or TSX edit, inspect the edited
hunks for closing `)`, `]`, and `}` placement and fix violations instead of
relying on formatter defaults.
- After every TypeScript or TSX edit, perform a style-only self-review of the
edited hunks before running verification or reporting completion. The task is
not complete while any edited TypeScript or TSX hunk violates these local
formatting rules.
- The TypeScript/TSX self-review must classify every edited leading or trailing
`)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not
apply a rule by glyph alone. A closing `)` for a function parameter list is
different from a closing `)` for a function call. A closing `}` for a block
is different from a closing `}` for an object, type literal, import list, or
destructuring pattern.
- The TSX-specific self-review must confirm there are no common Prettier-style
React component declarations with a multi-line destructured parameter.
- The TypeScript/TSX self-review must confirm multi-line function declaration
parameter `)` placement follows the detailed parameter-list rules below.
- The TypeScript/TSX self-review must confirm call-expression `)` is never at
the beginning of a line.
- The TypeScript/TSX self-review must confirm
object/type/import/destructuring `}` is not at the beginning of a line.
- The TypeScript/TSX self-review must confirm multi-line
function/lambda/callback/block `}` is on its own line and never at the end
of the previous line.
- The TypeScript/TSX self-review must confirm array `]` is not at the
beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading indentation follows
4-space logical indentation with tabs only as leading 8-space compression.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations. - Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function - Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward. declarations, unless imports, exports, or file boundaries make that awkward.
@@ -313,37 +272,16 @@ const value =
character. character.
- A leading tab is exactly equivalent to 8 leading spaces. - A leading tab is exactly equivalent to 8 leading spaces.
- In TypeScript and TSX function declarations, including `const` arrow - In TypeScript and TSX function declarations, including `const` arrow
function declarations, classify the parameter list before placing the closing function declarations, when the parameter list spans multiple lines, always
`)`. put the closing parenthesis at the beginning of its own line before the return
- If the parameter list itself is given its own multi-line block after the type or `=>`.
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 - In TypeScript and TSX, never place a closing parenthesis at the beginning of
a line except for a multi-line function declaration parameter list. a line except for a multi-line function declaration parameter list.
- Never place a closing square bracket at the beginning of a line. - Never place a closing square bracket at the beginning of a line.
- For object literals, type literals, import/export named bindings, destructuring - For object literals and other associative-array-style braces, do not place
patterns, and other associative-array-style braces, do not place the closing the closing brace at the beginning of a line. Function, lambda, callback, and
brace at the beginning of a line. Keep `}` on the same line as the final block closing braces are exempt and should stay on their own line when that
property, binding, or specifier unless that would violate the line limit. fits the local style.
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 - When writing braces on a single line in TypeScript or TSX JavaScript
context, put exactly one space inside the braces, as in `{ value }` or context, put exactly one space inside the braces, as in `{ value }` or
`{ key: value }`. `{ key: value }`.
@@ -362,585 +300,6 @@ const value =
- Do not use a leading semicolon for expression statements such as - Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`. explicitly, for example with `void`.
- Do not insert `void` mechanically before ordinary event-handler or callback
calls merely because the callee returns a `Promise`. If the surrounding code
already uses a plain expression statement such as
`onClick={() => requestActiveTab ('theme')}`, preserve that style unless a
local rule or type requirement truly forces `void`. Adding unnecessary
`void` is a medium-severity style violation.
- When fixing formatting, never change operator binding, evaluation order,
tuple/comma behaviour, or any other expression structure. A formatting pass
that changes how `!==`, `??`, `?:`, `&&`, `||`, or `,` binds is a capital
offence.
- In TypeScript and TSX, do not mechanically verticalise short call tails,
short object literals, or short callback bodies that already fit the local
line-length and delimiter rules. Expanding compact local code without a
readability need is a minor style violation.
- In TypeScript and TSX, treat short object literals, short returned objects,
short typed object shapes, and short function-call object arguments as
compact associative syntax by default. If they fit locally, keep the opening
`{` on the same line as the first property and keep the closing `}` on the
same line as the final property. Do not explode them into vertical
Prettier-style blocks without a concrete readability or line-length reason.
- In TypeScript and TSX, when a compact associative form is broken across two
lines, keep the opening `{` with the first property and the closing `}` with
the final property. A shape like `{\n key: value,\n}` is wrong even when
line length would permit it.
- In TypeScript and TSX, when a short call such as `apiGet<...> (...)`,
`apiPatch<...> (...)`, `setState (...)`, `updateClientSettings (...)`, or
`window.dispatchEvent (...)` already fits within the local style, do not
add extra vertical layers around the object argument or callback body.
- In TypeScript and TSX, after editing a file, inspect the file tail and
remove meaningless blank lines before `export default` or the end of file.
A stray empty line before `export default` is a minor style offence.
- Use correct British English spelling for new identifiers, filenames,
component names, helper names, comments, and developer-facing prose unless
editing an already established American-English API that must keep its
existing spelling for compatibility.
- Prefer British English spellings such as `behaviour`, `colour`, `realise`,
`theatre`, `centre`, `favourite`, `optimise`, and `catalogue`.
- Avoid American or Canadian spellings such as `behavior`, `color`, `realize`,
`theater`, `center`, `favorite`, `optimize`, and `catalog`.
- Even when an external library or API uses the wrong spelling, prefer to
correct it at the local boundary and use proper British English names in this
codebase. For example, prefer `import { color: colour } from '@external-lib'`
over spreading `color` through local code.
- Apply the same boundary correction to object destructuring, wrapper helpers,
adapter layers, and local variable names when doing so does not break the
external contract.
- For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not
`BehaviorSettingsSection.tsx`.
### Additional style offences and penalties
Use this section for recurrent failure modes that are easy to miss when
applying the delimiter rules mechanically.
#### Unnecessary `void` before ordinary callbacks
Bad:
```ts
onClick={() => void requestActiveTab ('theme')}
```
Good:
```ts
onClick={() => requestActiveTab ('theme')}
```
Penalty: medium-severity offence. The code still works, but it pollutes the
local style and usually signals that the edit was made mechanically rather than
by reading the surrounding code.
#### Formatting that changes expression structure
Bad:
```ts
const hasUnsavedChanges =
serialiseThemeDraft (draftThemeSlots)
!== (serialiseThemeDraft (savedThemeSlots),
draftActiveThemeMode !== savedThemeMode)
```
Good:
```ts
const hasUnsavedChanges =
serialiseThemeDraft (draftThemeSlots)
!== serialiseThemeDraft (savedThemeSlots)
|| draftThemeMode !== savedThemeMode
```
Penalty: capital offence. Formatting must never corrupt the meaning of an
expression.
#### Mechanical vertical expansion of compact local code
Bad:
```ts
const nextState = {
open,
activeTab }
setDirtyStates (
current => ({
current,
key: value }),
)
```
Good:
```ts
const nextState = { open, activeTab }
setDirtyStates (
current => ({
current,
key: value }))
```
Penalty: minor offence when semantics stay intact. It is not necessarily
broken, but it ignores the repository's compact associative style.
#### Prettier-style exploded object literals that should stay compact
Bad:
```ts
const next = {
enabled: keyboard?.enabled !== false,
bindings: normaliseKeyboardBindings (keyboard?.bindings),
}
```
Good:
```ts
const next = {
enabled: keyboard?.enabled !== false,
bindings: normaliseKeyboardBindings (keyboard?.bindings) }
```
Also good when short enough:
```ts
const next = { enabled: keyboard?.enabled !== false,
bindings: normaliseKeyboardBindings (keyboard?.bindings) }
```
Rule: for compact associative syntax, keep `{` with the first property and `}`
with the final property unless line length truly forces a different shape.
Do not mechanically apply the common Prettier block form.
Penalty: medium-severity offence. The code still runs, but it violates a core
repository formatting habit that must be preserved consistently.
#### Exploded object arguments inside short calls
Bad:
```ts
updateClientSettings (settings => ({
...settings,
keyboard: next,
}))
```
Good:
```ts
updateClientSettings (settings => ({
...settings,
keyboard: next }))
```
Rule: a call-expression closing `)` must not drift onto its own line, and a
short object argument should not be exploded more than necessary. Keep the
callback's returned object compact when it fits.
Penalty: medium-severity offence. This usually means the edit was formatted
mechanically instead of by reading the local style.
#### Stray blank line before `export default`
Bad:
```ts
const Component: FC = () => {
return <div/>
}
export default Component
```
Good:
```ts
const Component: FC = () => {
return <div/>
}
export default Component
```
Rule: do not leave an extra empty line between the last top-level declaration
and `export default` unless some surrounding file structure genuinely requires
visual separation.
Penalty: minor offence. It is small, but it is easy to avoid and should not
recur.
### Frontend delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit.
#### Import and export named bindings
Bad:
```ts
import {
Button,
Card,
} from '@/components/ui'
```
Good:
```ts
import { Button,
Card } from '@/components/ui'
```
Also acceptable when short:
```ts
import { Button, Card } from '@/components/ui'
```
Rule: named-binding `}` is associative-array-style syntax. It must not be alone
at the beginning of a line. Prefer keeping `{` with the first binding and `}`
with the final binding when this fits the line limit.
Penalty: capital offence. This is one of the core associative-brace rules.
#### Type literals
Bad:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
}
```
Good:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void }
```
Rule: type-literal `}` is not a block close. It must stay on the same line as
the final property unless that would break the hard line limit.
Penalty: capital offence. Mistaking a type-literal `}` for a block `}` is one
of the recurring death-class errors.
#### Object literals
Bad:
```ts
const value = {
open,
activeScope,
}
```
Good:
```ts
const value = {
open,
activeScope }
```
Bad:
```ts
const value = useMemo (() => ({
open,
activeScope,
}), [open, activeScope])
```
Good:
```ts
const value = useMemo (() => ({
open,
activeScope }), [open, activeScope])
```
Rule: object-literal `}` is associative-array-style syntax. It must not be on a
line by itself. Keep it with the final property, and keep call `)` off the
beginning of a line.
Penalty: capital offence. Wrong object-literal `}` placement and wrong call
`)` placement are both death-class violations here.
#### Destructuring parameters
Bad:
```tsx
const Component: FC<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.
Penalty: capital offence. This pattern is a primary source of repeated TSX
style regressions.
#### Inline typed destructuring parameter
Good:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<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.
Penalty: capital offence. Misclassifying this case means the delimiter review
has failed at the syntax-role level.
#### Multi-line normal parameter list
Bad:
```ts
const updateDraft = <Key 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.
Penalty: capital offence. This is the main allowed exception for a leading `)`,
so getting it wrong destroys the whole rule set.
#### Function and callback blocks
Bad:
```ts
const handleSave = () => { save ()
}
```
Bad:
```ts
const handleSave = () => {
save () }
```
Good:
```ts
const handleSave = () => {
save ()
}
```
Rule: block `}` closes executable code, not associative data. Multi-line
function, lambda, callback, `if`, `for`, `switch`, and similar block braces
belong on their own line.
Penalty: capital offence. Confusing block `}` with associative `}` is a
death-class delimiter error.
#### Function and method calls
Bad:
```ts
const value = compute (
first,
second
)
```
Good:
```ts
const value = compute (
first,
second)
```
Rule: call-expression `)` must not be at the beginning of a line. The exception
for leading `)` applies only to function declaration parameter lists, never to
calls.
Penalty: capital offence. A leading call `)` is one of the clearest death-class
violations in this codebase.
#### JSX closing markers
Bad:
```tsx
<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>)`.
Penalty: medium-severity offence. It is usually not semantically broken, but it
is a conspicuous TSX-style failure.
#### Arrays and tuples
Bad:
```ts
const items = [
first,
second,
]
```
Good:
```ts
const items = [
first,
second]
```
Good when short:
```ts
const items = [first, second]
```
Rule: array and tuple `]` must not be at the beginning of a line. Keep it with
the final element unless that would break the hard line limit.
Penalty: capital offence. A leading `]` is forbidden by the same class of hard
delimiter rule as leading associative `}` and leading call `)`.
#### Single-line braces
Good:
```ts
const next = { ...current, enabled: true }
const tag = { id, name }
```
Bad:
```tsx
<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.
Penalty: minor offence. This is not normally semantic breakage, but it still
counts as a style miss.
#### Final TypeScript/TSX self-review checklist
Before reporting completion after a TypeScript or TSX edit, check the edited
hunks line by line. Unless a step explicitly mentions JSX, it applies equally
to `.ts` and `.tsx`:
1. No import/export/type/object/destructuring `}` appears alone at the beginning
of a line.
2. No call-expression `)` appears at the beginning of a line.
3. Any leading `)` is definitely closing a function declaration parameter list,
not a call.
4. Any parameter-list `)` at the end of a line is valid because the parameter
list itself was not split, only an inline type or destructuring shape was.
5. No array or tuple `]` appears at the beginning of a line.
6. Multi-line executable block `}` is on its own line.
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading indentation is 4-space logical indentation with tabs used only as
leading 8-space compression.
10. No line has trailing whitespace.
Preferred: Preferred:
@@ -976,7 +335,7 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
- Do not scan or summarize dependency/generated/runtime directories such as - Do not scan or summarize dependency/generated/runtime directories such as
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed. `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication - Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
behaviour, inspect the related request specs and service objects. behavior, inspect the related request specs and service objects.
- If frontend code changes, run only non-test verification commands that - 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` apply, such as `npm run build` and `npm run lint`. Run `npm run test:run`
only when the user explicitly asks for tests. only when the user explicitly asks for tests.
@@ -993,4 +352,4 @@ A task is complete only when:
explained, explained,
- unrelated files are not changed, - unrelated files are not changed,
- migrations and schema are consistent when schema changes are made, - migrations and schema are consistent when schema changes are made,
- user-facing behaviour is documented when needed. - user-facing behavior is documented when needed.
-5
ファイルの表示
@@ -25,7 +25,6 @@ class PostVersionsController < ApplicationController
SQL SQL
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url', .select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags', 'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from', 'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before') 'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id q = q.where('post_versions.post_id = ?', post_id) if post_id
@@ -75,10 +74,6 @@ class PostVersionsController < ApplicationController
current: row.thumbnail_base, current: row.thumbnail_base,
prev: row.attributes['prev_thumbnail_base'] prev: row.attributes['prev_thumbnail_base']
}, },
video_ms: {
current: row.video_ms,
prev: row.attributes['prev_video_ms']
},
tags: build_version_tags(cur_tags, prev_tags), tags: build_version_tags(cur_tags, prev_tags),
original_created_from: { original_created_from: {
current: row.original_created_from&.iso8601, current: row.original_created_from&.iso8601,
+28 -138
ファイルの表示
@@ -1,10 +1,6 @@
class PostsController < ApplicationController class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true) Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
class VideoMsParseError < ArgumentError
;
end
def index def index
url = params[:url].presence url = params[:url].presence
title = params[:title].presence title = params[:title].presence
@@ -49,9 +45,7 @@ class PostsController < ApplicationController
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children, .preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections, tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -103,9 +97,7 @@ class PostsController < ApplicationController
def random def random
post = filtered_posts.preload(:uploaded_user, :parents, :children, post = filtered_posts.preload(:uploaded_user, :parents, :children,
active_post_tags: [:sections, tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
.order('RAND()') .order('RAND()')
.first .first
@@ -118,9 +110,7 @@ class PostsController < ApplicationController
post = post =
Post Post
.includes(:uploaded_user, :parents, :children, .includes(:uploaded_user, :parents, :children,
active_post_tags: [:sections, tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail .with_attached_thumbnail
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
@@ -135,7 +125,7 @@ class PostsController < ApplicationController
child_posts:, child_posts:,
sibling_posts:, sibling_posts:,
related:) related:)
.merge(tags: build_tag_tree_for(post)) .merge(tags: build_tag_tree_for(post.tags))
end end
def create def create
@@ -150,27 +140,24 @@ class PostsController < ApplicationController
original_created_from = params[:original_created_from] original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids parent_post_ids = parse_parent_post_ids
resized_thumbnail = thumbnail.present? ? Post.resized_thumbnail_attachment(thumbnail) : nil
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:) original_created_from:, original_created_before:)
post.thumbnail.attach(resized_thumbnail) if resized_thumbnail post.thumbnail.attach(thumbnail) if thumbnail.present?
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) => tags = Tag.normalise_tags!(tag_names, deny_deprecated: true)
{ tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = normalise_video_ms(tags) sync_post_tags!(post, tags)
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids) sync_parent_posts!(post, parent_post_ids)
post.resized_thumbnail!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end end
@@ -180,12 +167,6 @@ class PostsController < ApplicationController
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' } render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -242,8 +223,6 @@ class PostsController < ApplicationController
original_created_from:, original_created_from:,
original_created_before:, original_created_before:,
tag_names:, tag_names:,
video_ms_param: params[:video_ms],
duration_param: params[:duration],
parent_post_ids:) parent_post_ids:)
snapshot_to_apply = snapshot_to_apply =
@@ -274,16 +253,12 @@ class PostsController < ApplicationController
post.reload post.reload
json = PostRepr.base(post, current_user) json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post) json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok render json:, status: :ok
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] } render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
rescue Tag::DeprecatedTagNormalisationError rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -382,7 +357,7 @@ class PostsController < ApplicationController
def tagged_post_ids_for(name) = def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id) Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def sync_post_tags! post, desired_tags, sections def sync_post_tags! post, desired_tags
desired_tags.each do |t| desired_tags.each do |t|
t.save! if t.new_record? t.save! if t.new_record?
end end
@@ -401,21 +376,13 @@ class PostsController < ApplicationController
end end
end end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:)
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user) pt.discard_by!(current_user)
end end
end end
def build_tag_tree_for post def build_tag_tree_for tags
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? } tags = tags.reject(&:deprecated?).to_a
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
@@ -430,9 +397,6 @@ class PostsController < ApplicationController
root_ids = tag_ids - child_ids root_ids = tag_ids - child_ids
tags_by_id = tags.index_by(&:id) tags_by_id = tags.index_by(&:id)
sections_by_tag_id = post_tags.to_h { |post_tag|
[post_tag.tag_id, post_tag.sections.as_json(only: [:begin_ms, :end_ms])]
}
memo = { } memo = { }
@@ -440,10 +404,8 @@ class PostsController < ApplicationController
tag = tags_by_id[tag_id] tag = tags_by_id[tag_id]
return nil unless tag return nil unless tag
sections = sections_by_tag_id.fetch(tag_id, [])
if path.include?(tag_id) if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: [], sections:) return TagRepr.inline(tag).merge(children: [])
end end
if memo.key?(tag_id) if memo.key?(tag_id)
@@ -455,7 +417,7 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) } children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:) memo[tag_id] = TagRepr.inline(tag).merge(children:)
end end
root_ids.filter_map { |id| build_node.call(id, []) } root_ids.filter_map { |id| build_node.call(id, []) }
@@ -524,7 +486,6 @@ class PostsController < ApplicationController
def post_snapshot_from_version version def post_snapshot_from_version version
{ title: version.title, { title: version.title,
video_ms: version.respond_to?(:video_ms) ? version.video_ms : nil,
original_created_from: snapshot_time(version.original_created_from), original_created_from: snapshot_time(version.original_created_from),
original_created_before: snapshot_time(version.original_created_before), original_created_before: snapshot_time(version.original_created_before),
tag_names: editable_tag_names_from_version(version), tag_names: editable_tag_names_from_version(version),
@@ -537,7 +498,6 @@ class PostsController < ApplicationController
def post_snapshot_from_record post def post_snapshot_from_record post
{ title: post.title, { title: post.title,
video_ms: post.video_ms,
original_created_from: snapshot_time(post.original_created_from), original_created_from: snapshot_time(post.original_created_from),
original_created_before: snapshot_time(post.original_created_before), original_created_before: snapshot_time(post.original_created_before),
tag_names: editable_tag_names_from_post(post), tag_names: editable_tag_names_from_post(post),
@@ -545,41 +505,16 @@ class PostsController < ApplicationController
end end
def editable_tag_names_from_post post def editable_tag_names_from_post post
post post.tags.not_nico.where(deprecated_at: nil)
.post_tags .joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end end
def post_incoming_snapshot title:, original_created_from:, original_created_before:, def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids: tag_names:, parent_post_ids:
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
validate_video_sections!(video_ms, sections)
{ title:, { title:,
video_ms:,
original_created_from: snapshot_time(original_created_from), original_created_from: snapshot_time(original_created_from),
original_created_before: snapshot_time(original_created_before), original_created_before: snapshot_time(original_created_before),
tag_names: tags.uniq(&:id).map { |tag| tag_names: incoming_tag_names_for_snapshot(tag_names),
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
}.sort,
parent_post_ids: parent_post_ids.sort } parent_post_ids: parent_post_ids.sort }
end end
@@ -602,8 +537,11 @@ class PostsController < ApplicationController
value.to_s value.to_s
end end
def section_literal section def incoming_tag_names_for_snapshot raw_tag_names
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]" tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false,
deny_deprecated: true)
Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort
end end
def post_conflict_json post:, base_version_no:, base_snapshot:, def post_conflict_json post:, base_version_no:, base_snapshot:,
@@ -624,8 +562,6 @@ class PostsController < ApplicationController
def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot
[scalar_snapshot_change(:title, 'タイトル', [scalar_snapshot_change(:title, 'タイトル',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:video_ms, '動画時間',
base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)', scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)',
base_snapshot, current_snapshot, incoming_snapshot), base_snapshot, current_snapshot, incoming_snapshot),
scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)', scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)',
@@ -689,14 +625,11 @@ class PostsController < ApplicationController
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
post.update!(title: snapshot[:title], post.update!(title: snapshot[:title],
video_ms: snapshot[:video_ms],
original_created_from: snapshot[:original_created_from], original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before]) original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true, deny_deprecated: true)
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a readonly_tags = post.tags.nico.to_a
@@ -704,17 +637,14 @@ class PostsController < ApplicationController
tags = readonly_tags + editable_tags tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil sync_post_tags!(post, tags)
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, snapshot[:parent_post_ids]) sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end end
def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot
[:title, :video_ms, :original_created_from, :original_created_before].map { [:title, :original_created_from, :original_created_before].map {
[_1, merge_scalar_snapshot_value(base_snapshot[_1], [_1, merge_scalar_snapshot_value(base_snapshot[_1],
current_snapshot[_1], current_snapshot[_1],
incoming_snapshot[_1])] incoming_snapshot[_1])]
@@ -758,44 +688,4 @@ class PostsController < ApplicationController
render_validation_error record render_validation_error record
end end
end end
def normalise_video_ms tags, video_ms_param: params[:video_ms], duration_param: params[:duration]
return nil unless tags.any? { _1.id == Tag.video.id }
if video_ms_param.present?
video_ms = Integer(video_ms_param, exception: false)
raise VideoMsParseError unless video_ms&.positive?
return video_ms
end
return nil if duration_param.blank?
video_ms = Tag.time_to_ms!(duration_param.to_s, tag_name: '動画時間')
raise VideoMsParseError unless video_ms.positive?
video_ms
rescue Tag::SectionLiteralParseError
raise VideoMsParseError
end
def validate_video_sections! video_ms, sections
return unless video_ms
sections.each_value do |ranges|
ranges.each do |begin_ms, end_ms|
if begin_ms >= video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の開始が動画時間以上です.'
raise ActiveRecord::RecordInvalid, post
end
if end_ms && end_ms > video_ms
post = Post.new
post.errors.add :video_ms, 'タグ区間の終端が動画時間を超えてゐます.'
raise ActiveRecord::RecordInvalid, post
end
end
end
end
end end
-61
ファイルの表示
@@ -1,61 +0,0 @@
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
-38
ファイルの表示
@@ -1,38 +0,0 @@
class UserThemeSlotsController < ApplicationController
wrap_parameters false
def index
return head :unauthorized unless current_user
render json: current_user
.theme_slots
.order(:base_theme, :slot_no)
.map { |slot| slot.serializable_hash }
end
def update
return head :unauthorized unless current_user
base_theme = params[:base_theme].to_s
slot_no = params[:slot_no].to_i
tokens = params[:tokens]
unless UserThemeSlot::BASE_THEMES.include?(base_theme)
return render_validation_error fields: { base_theme: ['値が不正です.'] }
end
unless UserThemeSlot::SLOT_NOS.include?(slot_no)
return render_validation_error fields: { slot_no: ['値が不正です.'] }
end
unless tokens.is_a?(ActionController::Parameters) || tokens.is_a?(Hash)
return render_validation_error fields: { tokens: ['JSON object で指定してください.'] }
end
slot = UserThemeSlot.find_or_initialize_by(user: current_user,
base_theme:,
slot_no:)
slot.tokens = tokens.is_a?(ActionController::Parameters) ? tokens.to_unsafe_h : tokens
slot.save!
render json: slot.serializable_hash, status: :ok
end
end
+7 -52
ファイルの表示
@@ -1,19 +1,5 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'mini_magick' require 'mini_magick'
require 'stringio'
def self.resized_thumbnail_attachment(upload)
upload.rewind
image = MiniMagick::Image.read(upload.read)
image.resize '180x180'
image.format 'jpg'
{ io: StringIO.new(image.to_blob),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
ensure
upload.rewind
end
belongs_to :uploaded_user, class_name: 'User', optional: true belongs_to :uploaded_user, class_name: 'User', optional: true
@@ -60,7 +46,6 @@ class Post < ApplicationRecord
before_validation :normalise_url before_validation :normalise_url
validates :url, presence: true, uniqueness: true validates :url, presence: true, uniqueness: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range validate :validate_original_created_range
validate :url_must_be_http_url validate :url_must_be_http_url
@@ -84,42 +69,7 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end end
def snapshot_tag_names def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ section.end_ms ? Post.ms_to_time(section.end_ms) : '' }]"
end
def self.ms_to_time ms
total_s = ms / 1_000
s = total_s % 60
min = (total_s / 60) % 60
h = total_s / 3_600
remainder_ms = ms % 1_000
base =
if h.positive?
'%d:%02d:%02d' % [h, min, s]
else
'%d:%02d' % [min, s]
end
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id) def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
@@ -137,7 +87,12 @@ class Post < ApplicationRecord
def resized_thumbnail! def resized_thumbnail!
return unless thumbnail.attached? return unless thumbnail.attached?
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download))) image = MiniMagick::Image.read(thumbnail.download)
image.resize '180x180'
thumbnail.purge
thumbnail.attach(io: File.open(image.path),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg')
end end
private private
-6
ファイルの表示
@@ -10,12 +10,6 @@ class PostTag < ApplicationRecord
belongs_to :created_user, class_name: 'User', optional: true belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
dependent: :delete_all,
inverse_of: :post_tag
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validates :post_id, uniqueness: { validates :post_id, uniqueness: {
-20
ファイルの表示
@@ -1,20 +0,0 @@
class PostTagSection < ApplicationRecord
self.primary_key = :post_id, :tag_id, :begin_ms
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
validates :begin_ms, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :end_ms, numericality: { only_integer: true, greater_than: :begin_ms },
allow_nil: true
end
-1
ファイルの表示
@@ -5,7 +5,6 @@ class PostVersion < ApplicationRecord
belongs_to :parent, class_name: 'Post', optional: true belongs_to :parent, class_name: 'Post', optional: true
validates :url, presence: true validates :url, presence: true
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :validate_original_created_range validate :validate_original_created_range
+2 -47
ファイルの表示
@@ -1,52 +1,7 @@
class Setting < ApplicationRecord 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 belongs_to :user
validates :user_id, presence: true validates :user_id, presence: true
validates :user_id, uniqueness: true validates :key, presence: true, length: { maximum: 255 }
validates :value, presence: 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 end
+3 -112
ファイルの表示
@@ -17,16 +17,6 @@ class Tag < ApplicationRecord
end end
end end
class SectionLiteralParseError < ArgumentError
attr_reader :tag_name, :literal
def initialize tag_name, literal
@tag_name = tag_name
@literal = literal
super("invalid section literal for tag #{ tag_name }: #{ literal }")
end
end
has_many :post_tags, inverse_of: :tag has_many :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
@@ -115,60 +105,26 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true, def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true, with_no_deerjikist: true,
deny_nico: true, deny_nico: true,
deny_deprecated: false, deny_deprecated: false
with_sections: false
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
sections = { }
tags = tag_names.map do |name| tags = tag_names.map do |name|
raw_name = name
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
name = name.sub(/\A#{ pf }/i, '')
sections_by_tag = []
while (match = name.match(/\A(\S*?)\[([^\[\]\s]*)-([^\[\]\s]*)\](\S*)\z/))
name = "#{ match[1] }#{ match[4] }"
next if match[2].empty? && match[3].empty?
sections_by_tag << normalise_section_range!(
begin_raw: match[2],
end_raw: match[3],
tag_name: name)
end
if name.include?('[') || name.include?(']')
raise SectionLiteralParseError.new(raw_name, raw_name)
end
name = TagName.canonicalise(name).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
if deny_deprecated && tag.deprecated? if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name] raise DeprecatedTagNormalisationError, [tag.name]
end end
tag.update!(category: cat) if cat && tag.category != cat tag.update!(category: cat) if cat && tag.category != cat
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
sections[tag.id] = merge_section_ranges(sections[tag.id])
sections.delete(tag.id) if sections[tag.id] == [[0, nil]]
end end
end end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) } tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq!(&:id) tags.uniq(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
end end
def self.expand_parent_tags tags def self.expand_parent_tags tags
@@ -194,45 +150,6 @@ class Tag < ApplicationRecord
(result + tags).uniq { |t| t.id } (result + tags).uniq { |t| t.id }
end end
def self.normalise_section_range! begin_raw:, end_raw:, tag_name:
begin_ms = begin_raw.empty? ? 0 : time_to_ms!(begin_raw, tag_name:)
end_ms = end_raw.empty? ? nil : time_to_ms!(end_raw, tag_name:)
if end_ms
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
end_ms = begin_ms + 1 if begin_ms == end_ms
end
[begin_ms, end_ms]
end
def self.merge_section_ranges ranges
sorted_ranges = ranges.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
merged = []
sorted_ranges.each do |begin_ms, end_ms|
if merged.empty?
merged << [begin_ms, end_ms]
next
end
last_begin_ms, last_end_ms = merged[-1]
if last_end_ms.nil? || begin_ms <= last_end_ms
merged[-1] = [last_begin_ms, merge_section_end(last_end_ms, end_ms)]
else
merged << [begin_ms, end_ms]
end
end
merged
end
def self.merge_section_end left_end_ms, right_end_ms
return nil if left_end_ms.nil? || right_end_ms.nil?
[left_end_ms, right_end_ms].max
end
def self.find_or_create_by_tag_name! name, category: def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip) tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id? tn = tn.canonical if tn.canonical_id?
@@ -329,32 +246,6 @@ class Tag < ApplicationRecord
end end
end end
def self.time_to_ms! str, tag_name:
match =
case str
when /\A(?<seconds>\d+)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: nil, seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<minutes>\d+):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: nil, minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
when /\A(?<hours>\d+):(?<minutes>[0-5]?\d):(?<seconds>[0-5]?\d)(?:\.(?<ms>\d{1,3}))?\z/
{ hours: Regexp.last_match[:hours],
minutes: Regexp.last_match[:minutes],
seconds: Regexp.last_match[:seconds],
ms: Regexp.last_match[:ms] }
end
raise SectionLiteralParseError.new(tag_name, str) unless match
total_s = match[:seconds].to_i
total_s += match[:minutes].to_i * 60 if match[:minutes]
total_s += match[:hours].to_i * 3_600 if match[:hours]
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
def nico_tags_cannot_be_deprecated def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present? if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.' errors.add :deprecated_at, 'ニコタグは廃止できません.'
-24
ファイルの表示
@@ -6,7 +6,6 @@ class TagImplication < ApplicationRecord
validates :parent_tag_id, presence: true validates :parent_tag_id, presence: true
validate :parent_tag_mustnt_be_itself validate :parent_tag_mustnt_be_itself
validate :parent_tag_mustnt_create_cycle
private private
@@ -15,27 +14,4 @@ class TagImplication < ApplicationRecord
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.' errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
end end
end end
def parent_tag_mustnt_create_cycle
return if tag_id.blank? || parent_tag_id.blank?
return if errors[:parent_tag_id].present?
seen = { }
stack = [parent_tag_id]
until stack.empty?
current_id = stack.pop
next if seen[current_id]
seen[current_id] = true
if current_id == tag_id
errors.add :parent_tag_id, '親タグに子孫タグを指定すると循環します.'
errors.add :base, 'タグの親子関係が循環します.'
return
end
stack.concat(TagImplication.where(tag_id: current_id).pluck(:parent_tag_id))
end
end
end end
+1 -2
ファイルの表示
@@ -7,8 +7,7 @@ class User < ApplicationRecord
has_many :created_posts, has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
has_one :setting, dependent: :destroy has_many :settings
has_many :theme_slots, class_name: 'UserThemeSlot', dependent: :destroy
has_many :user_ips, dependent: :destroy has_many :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy has_many :user_post_views, dependent: :destroy
-27
ファイルの表示
@@ -1,27 +0,0 @@
class UserThemeSlot < ApplicationRecord
BASE_THEMES = ['light', 'dark'].freeze
SLOT_NOS = [1, 2, 3].freeze
belongs_to :user
validates :user_id, presence: true
validates :base_theme, presence: true, inclusion: { in: BASE_THEMES }
validates :slot_no, presence: true, inclusion: { in: SLOT_NOS }
validates :tokens, presence: true
validates :user_id, uniqueness: { scope: [:base_theme, :slot_no] }
validate :tokens_must_be_object
def serializable_hash(options = nil)
hash = { only: [:base_theme, :slot_no, :tokens, :created_at, :updated_at] }
super(hash.merge(options || {}))
end
private
def tokens_must_be_object
return if tokens.is_a?(Hash)
errors.add(:tokens, 'は JSON object で指定してください.')
end
end
+3 -12
ファイルの表示
@@ -8,7 +8,6 @@ module PostRepr
:url, :url,
:title, :title,
:thumbnail_base, :thumbnail_base,
:video_ms,
:original_created_from, :original_created_from,
:original_created_before, :original_created_before,
:created_at, :created_at,
@@ -19,7 +18,7 @@ module PostRepr
def base post, current_user = nil def base post, current_user = nil
json = common(post) json = common(post)
json['tags'] = tag_json(post) json['tags'] = tag_json(post.tags)
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user) json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false json['viewed'] = current_user ? current_user.viewed?(post) : false
json json
@@ -53,16 +52,8 @@ module PostRepr
.merge('thumbnail' => thumbnail_url(post)) .merge('thumbnail' => thumbnail_url(post))
end end
def tag_json post def tag_json tags
post tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) }
.active_post_tags
.reject { _1.tag.deprecated? }
.sort_by { _1.tag.name }
.map { |post_tag|
TagRepr.inline(post_tag.tag).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
}
end end
def thumbnail_url post def thumbnail_url post
-1
ファイルの表示
@@ -23,7 +23,6 @@ class PostVersionRecorder < VersionRecorder
{ title: @record.title, { title: @record.title,
url: @record.url, url: @record.url,
thumbnail_base: @record.thumbnail_base, thumbnail_base: @record.thumbnail_base,
video_ms: @record.video_ms,
tags: @record.snapshot_tag_names.join(' '), tags: @record.snapshot_tag_names.join(' '),
parent_post_ids: @record.snapshot_parent_post_ids.join(' '), parent_post_ids: @record.snapshot_parent_post_ids.join(' '),
original_created_from: @record.original_created_from, original_created_from: @record.original_created_from,
-5
ファイルの表示
@@ -81,11 +81,6 @@ Rails.application.routes.draw do
end end
end end
get 'users/settings', to: 'user_settings#show'
patch 'users/settings', to: 'user_settings#update'
get 'users/theme_slots', to: 'user_theme_slots#index'
put 'users/theme_slots/:base_theme/:slot_no', to: 'user_theme_slots#update'
resources :users, only: [:create, :update] do resources :users, only: [:create, :update] do
collection do collection do
post :verify post :verify
-9
ファイルの表示
@@ -1,9 +0,0 @@
class AddVideoMsToPosts < ActiveRecord::Migration[8.0]
def change
add_column :posts, :video_ms, :integer
add_index :posts, [:video_ms, :id], name: 'idx_posts_video_ms_id'
add_check_constraint :posts, 'video_ms IS NULL OR video_ms > 0',
name: 'chk_posts_video_ms_positive'
end
end
-19
ファイルの表示
@@ -1,19 +0,0 @@
class CreatePostTagSections < ActiveRecord::Migration[8.0]
def change
create_table :post_tag_sections, primary_key: [:post_id, :tag_id, :begin_ms] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :tag, null: false, foreign_key: true, index: false
t.integer :begin_ms, null: false
t.integer :end_ms, null: true
t.timestamps
t.index [:post_id, :begin_ms], name: 'idx_post_tag_sections_post_id_begin_ms'
t.check_constraint 'begin_ms >= 0',
name: 'chk_post_tag_sections_begin_ms_natural'
t.check_constraint 'end_ms IS NULL OR begin_ms < end_ms',
name: 'chk_post_tag_sections_end_ms_after_begin_ms'
end
end
end
-9
ファイルの表示
@@ -1,9 +0,0 @@
class AddVideoMsToPostVersions < ActiveRecord::Migration[8.0]
def change
add_column :post_versions, :video_ms, :integer
add_index :post_versions, [:video_ms, :post_id], name: 'idx_post_versions_video_ms_post_id'
add_check_constraint :post_versions, 'video_ms IS NULL OR video_ms > 0',
name: 'chk_post_versions_video_ms_positive'
end
end
-31
ファイルの表示
@@ -1,31 +0,0 @@
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
-22
ファイルの表示
@@ -1,22 +0,0 @@
class CreateUserThemeSlots < ActiveRecord::Migration[8.0]
def change
create_table :user_theme_slots do |t|
t.references :user, null: false, foreign_key: true
t.string :base_theme, null: false
t.integer :slot_no, null: false
t.json :tokens, null: false
t.timestamps
end
add_index :user_theme_slots,
[:user_id, :base_theme, :slot_no],
unique: true,
name: 'index_user_theme_slots_on_user_theme_and_slot'
add_check_constraint :user_theme_slots,
"base_theme IN ('light', 'dark')",
name: 'user_theme_slots_base_theme_valid'
add_check_constraint :user_theme_slots,
'slot_no BETWEEN 1 AND 3',
name: 'user_theme_slots_slot_no_valid'
end
end
生成ファイル
+19 -56
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do ActiveRecord::Schema[8.0].define(version: 2026_06_26_010000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -298,19 +298,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end end
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.integer "begin_ms", null: false
t.integer "end_ms"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
t.index ["tag_id"], name: "fk_rails_8be3847903"
t.check_constraint "(`end_ms` is null) or (`begin_ms` < `end_ms`)", name: "chk_post_tag_sections_end_ms_after_begin_ms"
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
@@ -344,12 +331,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.integer "video_ms"
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["post_id"], name: "index_post_versions_on_post_id"
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end end
@@ -364,36 +348,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.integer "video_ms"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.string "key", null: false
t.json "value", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "theme", default: "system", null: false t.index ["user_id"], name: "index_settings_on_user_id"
t.string "auto_fetch_title", default: "manual", null: false
t.string "auto_fetch_thumbnail", default: "manual", null: false
t.string "wiki_editor_mode", default: "split", null: false
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -441,9 +407,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.string "event_type", null: false t.string "event_type", null: false
t.string "name", null: false t.string "name", null: false
t.string "category", null: false t.string "category", null: false
t.datetime "deprecated_at"
t.text "aliases", null: false t.text "aliases", null: false
t.text "parent_tag_ids", null: false t.text "parent_tag_ids", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at" t.index ["created_at"], name: "index_tag_versions_on_created_at"
@@ -456,10 +422,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false t.string "category", default: "general", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.datetime "deprecated_at"
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
@@ -581,19 +547,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["post_id"], name: "index_user_post_views_on_post_id" t.index ["post_id"], name: "index_user_post_views_on_post_id"
end end
create_table "user_theme_slots", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "base_theme", null: false
t.integer "slot_no", null: false
t.json "tokens", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "base_theme", "slot_no"], name: "index_user_theme_slots_on_user_theme_and_slot", unique: true
t.index ["user_id"], name: "index_user_theme_slots_on_user_id"
t.check_constraint "`base_theme` in (_utf8mb4'light',_utf8mb4'dark')", name: "user_theme_slots_base_theme_valid"
t.check_constraint "`slot_no` between 1 and 3", name: "user_theme_slots_slot_no_valid"
end
create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name" t.string "name"
t.string "inheritance_code", limit: 64, null: false t.string "inheritance_code", limit: 64, null: false
@@ -604,6 +557,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
t.index ["banned_at"], name: "index_users_on_banned_at" t.index ["banned_at"], name: "index_users_on_banned_at"
end end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false t.string "sha256", limit: 64, null: false
t.text "body", null: false t.text "body", null: false
@@ -712,8 +678,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
@@ -753,7 +717,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
add_foreign_key "user_ips", "users" add_foreign_key "user_ips", "users"
add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users" add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id" add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "tag_names"
-8
ファイルの表示
@@ -1,8 +0,0 @@
FactoryBot.define do
factory :post_tag_section do
association :post
association :tag
begin_ms { 1_000 }
end_ms { 2_000 }
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :post_tag do
association :post
association :tag
end
end
-8
ファイルの表示
@@ -1,8 +0,0 @@
FactoryBot.define do
factory :post do
sequence(:url) { |n| "https://example.com/factory-post-#{ n }" }
title { 'factory post' }
thumbnail_base { nil }
uploaded_user { nil }
end
end
-41
ファイルの表示
@@ -1,41 +0,0 @@
RSpec.describe PostTag, type: :model do
describe '#sections' do
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to contain_exactly(section)
end
it 'does not load sections for another tag on the same post' do
post = create(:post)
tag = create(:tag)
other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to be_empty
end
it 'allows open-ended sections' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: nil)
expect(section).to be_valid
expect(post_tag.sections).to contain_exactly(section)
end
end
end
-34
ファイルの表示
@@ -1,34 +0,0 @@
require 'rails_helper'
RSpec.describe Setting, type: :model do
it 'accepts the default typed user settings' do
setting = described_class.new({ user: create(:user) }.merge(described_class.defaults))
expect(setting).to be_valid
end
it 'requires one settings row per user' do
user = create(:user)
described_class.create!({ user: }.merge(described_class.defaults))
duplicate = described_class.new({ user: }.merge(described_class.defaults))
expect(duplicate).not_to be_valid
expect(duplicate.errors[:user_id]).to be_present
end
it 'validates enum-like settings columns' do
setting = described_class.new(
{ user: create(:user),
theme: 'neon',
auto_fetch_title: 'sometimes',
auto_fetch_thumbnail: 'sometimes',
wiki_editor_mode: 'sideways' })
expect(setting).not_to be_valid
expect(setting.errors[:theme]).to be_present
expect(setting.errors[:auto_fetch_title]).to be_present
expect(setting.errors[:auto_fetch_thumbnail]).to be_present
expect(setting.errors[:wiki_editor_mode]).to be_present
end
end
-36
ファイルの表示
@@ -1,36 +0,0 @@
require 'rails_helper'
RSpec.describe TagImplication, type: :model do
it 'rejects a parent tag that would create a cycle' do
child = create(:tag, name: 'tag_implication_cycle_child')
parent = create(:tag, name: 'tag_implication_cycle_parent')
described_class.create!(tag: child, parent_tag: parent)
implication = described_class.new(tag: parent, parent_tag: child)
expect(implication).not_to be_valid
expect(implication.errors[:parent_tag_id]).to include(
'親タグに子孫タグを指定すると循環します.'
)
expect(implication.errors[:base]).to be_present
end
it 'terminates even when existing data already contains a cycle' do
child = create(:tag, name: 'tag_implication_existing_cycle_child')
parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
described_class.create!(tag: parent, parent_tag: ancestor)
described_class.insert_all!(
[
{ tag_id: ancestor.id, parent_tag_id: parent.id,
created_at: Time.current, updated_at: Time.current }
]
)
implication = described_class.new(tag: child, parent_tag: parent)
expect(implication).to be_valid
end
end
+1 -100
ファイルの表示
@@ -19,95 +19,6 @@ RSpec.describe Tag, type: :model do
expect(error.tag_names).to eq([deprecated_tag.name]) expect(error.tag_names).to eq([deprecated_tag.name])
} }
end end
it 'rejects invalid section literals instead of treating them as zero' do
expect {
described_class.normalise_tags!(
['normalise_invalid_section[1:aa-2:00]'],
with_sections: true
)
}.to raise_error(Tag::SectionLiteralParseError)
end
it 'parses open-ended section literals' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'parses omitted begin as zero' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-1:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]])
end
it 'treats fully open section literals as plain tags' do
result = described_class.normalise_tags!(
['伊地知ニジカ[-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'treats [0:00-] as a plain tag' do
result = described_class.normalise_tags!(
['伊地知ニジカ[0:00-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections)[tag.id]).to be_nil
end
it 'expands zero-width sections to one millisecond' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-1:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]])
end
it 'swaps reversed section boundaries' do
result = described_class.normalise_tags!(
['伊地知ニジカ[2:00-1:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]])
end
it 'merges open-ended sections over later bounded sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-][2:00-3:00]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
it 'merges adjacent bounded and open-ended sections' do
result = described_class.normalise_tags!(
['伊地知ニジカ[1:00-3:00][3:00-]'],
with_sections: true
)
tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' }
expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]])
end
end end
describe '.expand_parent_tags' do describe '.expand_parent_tags' do
@@ -143,17 +54,7 @@ RSpec.describe Tag, type: :model do
first = create(:tag, name: 'expand_cycle_first') first = create(:tag, name: 'expand_cycle_first')
second = create(:tag, name: 'expand_cycle_second') second = create(:tag, name: 'expand_cycle_second')
TagImplication.create!(tag: first, parent_tag: second) TagImplication.create!(tag: first, parent_tag: second)
now = Time.current TagImplication.create!(tag: second, parent_tag: first)
TagImplication.insert_all!(
[
{
tag_id: second.id,
parent_tag_id: first.id,
created_at: now,
updated_at: now
}
]
)
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second) expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
end end
-40
ファイルの表示
@@ -1,40 +0,0 @@
require 'rails_helper'
RSpec.describe UserThemeSlot, type: :model do
it 'accepts a theme slot for one user, base theme, and slot number' do
slot = described_class.new(user: create(:user),
base_theme: 'light',
slot_no: 1,
tokens: { 'background' => '0 0% 100%' })
expect(slot).to be_valid
end
it 'requires unique slot numbers per user and base theme' do
user = create(:user)
described_class.create!(user:,
base_theme: 'dark',
slot_no: 2,
tokens: { 'background' => '222.2 84% 4.9%' })
duplicate = described_class.new(user:,
base_theme: 'dark',
slot_no: 2,
tokens: { 'background' => '0 0% 100%' })
expect(duplicate).not_to be_valid
expect(duplicate.errors[:user_id]).to be_present
end
it 'validates base theme, slot number, and token object shape' do
slot = described_class.new(user: create(:user),
base_theme: 'system',
slot_no: 4,
tokens: 'not-object')
expect(slot).not_to be_valid
expect(slot.errors[:base_theme]).to be_present
expect(slot.errors[:slot_no]).to be_present
expect(slot.errors[:tokens]).to be_present
end
end
-287
ファイルの表示
@@ -1,5 +1,4 @@
require 'rails_helper' require 'rails_helper'
require 'base64'
require 'set' require 'set'
include ActiveSupport::Testing::TimeHelpers include ActiveSupport::Testing::TimeHelpers
@@ -9,11 +8,6 @@ RSpec.describe 'Posts API', type: :request do
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
before do before do
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true) allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg'
)
end end
def create_nico_tag!(name) def create_nico_tag!(name)
@@ -25,18 +19,6 @@ RSpec.describe 'Posts API', type: :request do
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg') Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
end end
def real_thumbnail_upload
gif =
Base64.decode64(
'R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
Rack::Test::UploadedFile.new(
StringIO.new(gif),
'image/gif',
original_filename: 'thumbnail.gif'
)
end
def post_write_params params = { } def post_write_params params = { }
{ parent_post_ids: '' }.merge(params) { parent_post_ids: '' }.merge(params)
end end
@@ -54,7 +36,6 @@ RSpec.describe 'Posts API', type: :request do
title: post.title, title: post.title,
url: post.url, url: post.url,
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
video_ms: post.video_ms,
tags: post.snapshot_tag_names.join(' '), tags: post.snapshot_tag_names.join(' '),
parent_post_ids: post.snapshot_parent_post_ids.join(' '), parent_post_ids: post.snapshot_parent_post_ids.join(' '),
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
@@ -146,22 +127,6 @@ RSpec.describe 'Posts API', type: :request do
expect(all_tag_names).to include("spec_tag") expect(all_tag_names).to include("spec_tag")
end end
it 'keeps children and sections keys in non-detail tag responses' do
PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)
get '/posts'
expect(response).to have_http_status(:ok)
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
expect(tag_json.fetch('children')).to eq([])
expect(tag_json.fetch('sections')).to eq([
{ 'begin_ms' => 1_000, 'end_ms' => nil }
])
end
context "when q is provided" do context "when q is provided" do
it "filters posts by q (hit case)" do it "filters posts by q (hit case)" do
get "/posts", params: { tags: "spec_tag" } get "/posts", params: { tags: "spec_tag" }
@@ -731,66 +696,6 @@ RSpec.describe 'Posts API', type: :request do
expect(json['tags'][0]).to have_key('name') expect(json['tags'][0]).to have_key('name')
end end
it '201 when posting manually with a thumbnail' do
sign_in_as(member)
allow(Post).to receive(:resized_thumbnail_attachment).and_call_original
post '/posts', params: post_write_params(
title: 'thumbnail post',
url: 'https://example.com/thumbnail-post',
tags: 'spec_tag',
thumbnail: real_thumbnail_upload
)
expect(response).to have_http_status(:created)
post_record = Post.find(json.fetch('id'))
expect(post_record.thumbnail).to be_attached
expect(post_record.thumbnail.blob.content_type).to eq('image/jpeg')
expect { post_record.thumbnail.download }.not_to raise_error
end
it 'resizes the thumbnail before the create transaction begins' do
sign_in_as(member)
open_transactions = []
baseline_open_transactions = Post.connection.open_transactions
allow(Post).to receive(:resized_thumbnail_attachment) do |_upload|
open_transactions << Post.connection.open_transactions
{ io: StringIO.new('dummy'),
filename: 'resized_thumbnail.jpg',
content_type: 'image/jpeg' }
end
post '/posts', params: post_write_params(
title: 'transaction post',
url: 'https://example.com/transaction-post',
tags: 'spec_tag',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(open_transactions).to eq([baseline_open_transactions])
end
it 'returns 422 and does not create a post when thumbnail resize fails' do
sign_in_as(member)
allow(Post).to receive(:resized_thumbnail_attachment).and_raise(MiniMagick::Error)
expect {
post '/posts', params: post_write_params(
title: 'broken thumbnail post',
url: 'https://example.com/broken-thumbnail-post',
tags: 'spec_tag',
thumbnail: dummy_upload
)
}.not_to change(Post, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'thumbnail' => ['サムネイル画像の変換に失敗しました.']
)
end
it '201 and creates post + tags when member and tags have aliases' do it '201 and creates post + tags when member and tags have aliases' do
sign_in_as(member) sign_in_as(member)
@@ -862,198 +767,6 @@ RSpec.describe 'Posts API', type: :request do
expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent') expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent')
end end
it 'returns validation error for an invalid section literal' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid section literal',
url: 'https://example.com/invalid-section-literal',
tags: 'spec_tag[1:aa-2:00]',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.'
)
expect(json.fetch('errors')).to include(
'tags' => ['タグ区間の記法が不正です.']
)
end
it 'creates a video post with duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'video post',
url: 'https://example.com/video-post',
tags: '動画 spec_tag',
duration: '3:00.500',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
expect(json.fetch('video_ms')).to eq(180_500)
end
it 'creates a video post with number input duration seconds' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'video post seconds',
url: 'https://example.com/video-post-seconds',
tags: '動画 spec_tag',
duration: '180.5',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to eq(180_500)
expect(json.fetch('video_ms')).to eq(180_500)
end
it 'clears video_ms when the saved tags do not include 動画' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'non video post',
url: 'https://example.com/non-video-post',
tags: 'spec_tag',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
expect(Post.find(json.fetch('id')).video_ms).to be_nil
end
it 'returns validation error when a bounded section exceeds duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'too long section',
url: 'https://example.com/too-long-section',
tags: '動画 伊地知ニジカ[2:50-3:10]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'video_ms' => ['タグ区間の終端が動画時間を超えてゐます.']
)
end
it 'saves open-ended sections with end_ms NULL' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'open ended section literal',
url: 'https://example.com/open-ended-section-literal',
tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
section = PostTagSection.find_by!(post: created_post, tag:)
expect(section.begin_ms).to eq(60_000)
expect(section.end_ms).to be_nil
end
it 'does not save sections for [-]' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'fully open section literal',
url: 'https://example.com/fully-open-section-literal',
tags: '伊地知ニジカ[-]',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
end
it 'does not save sections for [0:00-]' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'zero open ended section literal',
url: 'https://example.com/zero-open-ended-section-literal',
tags: '伊地知ニジカ[0:00-]',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
created_post = Post.find(json.fetch('id'))
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: '伊地知ニジカ' })
expect(PostTagSection.find_by(post: created_post, tag:)).to be_nil
end
it 'returns end_ms null for open-ended sections in show response' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'show open ended section literal',
url: 'https://example.com/show-open-ended-section-literal',
tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
get "/posts/#{ json.fetch('id') }"
expect(response).to have_http_status(:ok)
tag_json = json.fetch('tags').find { |item| item['name'] == '伊地知ニジカ' }
expect(tag_json.fetch('sections')).to eq([
{ 'begin_ms' => 60_000, 'end_ms' => nil }
])
end
it 'allows open-ended sections when begin is within duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'valid open ended section literal',
url: 'https://example.com/valid-open-ended-section-literal',
tags: '動画 伊地知ニジカ[1:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:created)
end
it 'rejects open-ended sections when begin equals duration' do
sign_in_as(member)
post '/posts', params: post_write_params(
title: 'invalid open ended section literal',
url: 'https://example.com/invalid-open-ended-section-literal',
tags: '動画 伊地知ニジカ[3:00-]',
duration: '3:00',
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'video_ms' => ['タグ区間の開始が動画時間以上です.']
)
end
context "when nico tag already exists in tags" do context "when nico tag already exists in tags" do
before do before do
Tag.find_undiscard_or_create_by!( Tag.find_undiscard_or_create_by!(
-11
ファイルの表示
@@ -56,17 +56,6 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
end end
it 'returns 422 and does not create relation when the new link makes a cycle' do
TagImplication.create!(tag: parent, parent_tag: child)
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(TagImplication.where(tag: child, parent_tag: parent)).not_to exist
end
end end
context "when Tag.find raises (invalid ids)" do context "when Tag.find raises (invalid ids)" do
+3 -11
ファイルの表示
@@ -797,17 +797,7 @@ RSpec.describe 'Tags API', type: :request do
) )
TagImplication.create!(tag: first, parent_tag: root_material) TagImplication.create!(tag: first, parent_tag: root_material)
TagImplication.create!(tag: second, parent_tag: first) TagImplication.create!(tag: second, parent_tag: first)
now = Time.current TagImplication.create!(tag: first, parent_tag: second)
TagImplication.insert_all!(
[
{
tag_id: first.id,
parent_tag_id: second.id,
created_at: now,
updated_at: now
}
]
)
get '/tags/with-depth', params: { parent: root_material.id } get '/tags/with-depth', params: { parent: root_material.id }
@@ -1374,6 +1364,8 @@ RSpec.describe 'Tags API', type: :request do
end end
it 'parent_tags に指定すると循環する tag は 422 にする' do it 'parent_tags に指定すると循環する tag は 422 にする' do
pending '#332 で対応予定'
child = Tag.create!( child = Tag.create!(
tag_name: TagName.create!(name: 'put_cycle_child'), tag_name: TagName.create!(name: 'put_cycle_child'),
category: :general category: :general
-98
ファイルの表示
@@ -1,98 +0,0 @@
require 'rails_helper'
RSpec.describe 'user settings', type: :request do
describe 'GET /users/settings' do
it 'requires a current user' do
sign_out
get '/users/settings'
expect(response).to have_http_status(:unauthorized)
end
it 'returns defaults and creates a missing settings row' do
user = create(:user)
sign_in_as(user)
expect {
get '/users/settings'
}.to change(Setting, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json).to eq(
'theme' => 'system',
'auto_fetch_title' => 'manual',
'auto_fetch_thumbnail' => 'manual',
'wiki_editor_mode' => 'split')
expect(user.reload.setting).to be_present
end
end
describe 'PATCH /users/settings' do
it 'requires a current user' do
sign_out
patch '/users/settings', params: { theme: 'dark' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates multiple typed settings at once' do
user = create(:user)
sign_in_as(user)
patch '/users/settings',
params: {
theme: 'dark',
auto_fetch_title: 'auto',
auto_fetch_thumbnail: 'off',
wiki_editor_mode: 'preview' },
as: :json
expect(response).to have_http_status(:ok)
expect(json).to include(
'theme' => 'dark',
'auto_fetch_title' => 'auto',
'auto_fetch_thumbnail' => 'off',
'wiki_editor_mode' => 'preview')
expect(user.reload.setting).to have_attributes(
theme: 'dark',
auto_fetch_title: 'auto',
auto_fetch_thumbnail: 'off',
wiki_editor_mode: 'preview')
end
it 'does not treat Rails parameter wrapping as an editable setting' do
user = create(:user)
sign_in_as(user)
patch '/users/settings',
params: { theme: 'light', user_setting: { theme: 'dark' } },
as: :json
expect(response).to have_http_status(:ok)
expect(user.reload.setting.theme).to eq('light')
end
it 'returns validation_error for type mismatches' do
sign_in_as(create(:user))
patch '/users/settings', params: { theme: 1 }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors')).to include(
'theme' => ['値の型が不正です.'])
end
it 'returns validation_error for invalid enum values' do
sign_in_as(create(:user))
patch '/users/settings', params: { theme: 'neon' }, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors').fetch('theme')).to be_present
end
end
end
-104
ファイルの表示
@@ -1,104 +0,0 @@
require 'rails_helper'
RSpec.describe 'user theme slots', type: :request do
describe 'GET /users/theme_slots' do
it 'requires a current user' do
sign_out
get '/users/theme_slots'
expect(response).to have_http_status(:unauthorized)
end
it 'returns only the current user slots in stable order' do
user = create(:user)
other_user = create(:user)
sign_in_as(user)
UserThemeSlot.create!(user:, base_theme: 'light', slot_no: 2, tokens: { 'a' => 1 })
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 1, tokens: { 'b' => 2 })
UserThemeSlot.create!(user: other_user,
base_theme: 'dark',
slot_no: 3,
tokens: { 'c' => 3 })
get '/users/theme_slots'
expect(response).to have_http_status(:ok)
expect(json.map { |slot| [slot.fetch('base_theme'), slot.fetch('slot_no')] }).to eq(
[['dark', 1], ['light', 2]])
expect(json.map { |slot| slot.fetch('tokens') }).to eq(
[{ 'b' => 2 }, { 'a' => 1 }])
end
end
describe 'PUT /users/theme_slots/:base_theme/:slot_no' do
it 'requires a current user' do
sign_out
put '/users/theme_slots/light/1',
params: { tokens: { background: '0 0% 100%' } },
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates a theme slot' do
user = create(:user)
sign_in_as(user)
expect {
put '/users/theme_slots/light/1',
params: { tokens: { background: '0 0% 100%', tagColours: { nico: '#ffffff' } } },
as: :json
}.to change(UserThemeSlot, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json).to include('base_theme' => 'light', 'slot_no' => 1)
expect(json.fetch('tokens')).to include(
'background' => '0 0% 100%',
'tagColours' => { 'nico' => '#ffffff' })
expect(user.theme_slots.find_by!(base_theme: 'light', slot_no: 1).tokens).to include(
'background' => '0 0% 100%')
end
it 'updates an existing theme slot instead of creating a duplicate' do
user = create(:user)
sign_in_as(user)
UserThemeSlot.create!(user:, base_theme: 'dark', slot_no: 2, tokens: { 'old' => true })
expect {
put '/users/theme_slots/dark/2',
params: { tokens: { background: '222.2 84% 4.9%' } },
as: :json
}.not_to change(UserThemeSlot, :count)
expect(response).to have_http_status(:ok)
expect(user.theme_slots.find_by!(base_theme: 'dark', slot_no: 2).tokens).to eq(
'background' => '222.2 84% 4.9%')
end
it 'rejects invalid path parameters and token shapes' do
sign_in_as(create(:user))
put '/users/theme_slots/system/4',
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'base_theme' => ['値が不正です.'])
end
it 'rejects non-object tokens for valid slots' do
sign_in_as(create(:user))
put '/users/theme_slots/light/1',
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tokens' => ['JSON object で指定してください.'])
end
end
end
+2 -434
ファイルの表示
@@ -118,455 +118,23 @@ pass or the remaining failure is clearly blocked.
short Japanese labels that fit the control. short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including - Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it. old-kana wording where the file already uses it.
- When adding dynamic tag colour classes, update `tailwind.config.js` safelist - When adding dynamic tag color classes, update `tailwind.config.js` safelist
if the class cannot be statically detected. if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval. - Do not introduce new UI libraries or production dependencies without approval.
## TypeScript and TSX formatting ## TSX formatting
- The delimiter-placement and line-breaking rules in this section apply to
both plain TypeScript `.ts` and TSX `.tsx`, unless a bullet explicitly says
it is JSX- or React-specific.
- Preserve compact TSX expression shapes such as inline ternary branches and - Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them. closing `</div>)` forms when nearby code uses them.
- Treat TypeScript and TSX formatting rules as hard constraints, not
preferences. Before finishing a TypeScript or TSX edit, inspect the edited
hunks for closing `)`, `]`, and `}` placement and fix violations instead of
relying on formatter defaults.
- After every TypeScript or TSX edit, perform a style-only self-review of the
edited hunks before running verification or reporting completion. The task is
not complete while any edited TypeScript or TSX hunk violates these local
formatting rules.
- The TypeScript/TSX self-review must classify every edited leading or trailing
`)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not
apply a rule by glyph alone. A closing `)` for a function parameter list is
different from a closing `)` for a function call. A closing `}` for a block
is different from a closing `}` for an object, type literal, import list, or
destructuring pattern.
- The TSX-specific self-review must confirm there are no common Prettier-style
React component declarations with a multi-line destructured parameter.
- The TypeScript/TSX self-review must confirm multi-line function declaration
parameter `)` placement follows the detailed parameter-list rules below.
- The TypeScript/TSX self-review must confirm call-expression `)` is never at
the beginning of a line.
- The TypeScript/TSX self-review must confirm
object/type/import/destructuring `}` is not at the beginning of a line.
- The TypeScript/TSX self-review must confirm multi-line
function/lambda/callback/block `}` is on its own line and never at the end
of the previous line.
- The TypeScript/TSX self-review must confirm array `]` is not at the
beginning of a line.
- The TSX-specific self-review must confirm JSX closing markers and closing
parentheses keep the surrounding compact style.
- The TypeScript/TSX self-review must confirm leading indentation follows
4-space logical indentation with tabs only as leading 8-space compression.
- For long Tailwind `className` strings, wrap across lines only when needed. - For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation - Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression. rule, using tabs only as leading 8-space compression.
- 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 - Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line. single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans - Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement. 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. - Avoid reformatting unrelated JSX.
### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit. Unless a subsection explicitly
mentions JSX, it applies equally to `.ts` and `.tsx`.
#### Import and export named bindings
Bad:
```ts
import {
Button,
Card,
} from '@/components/ui'
```
Good:
```ts
import { Button,
Card } from '@/components/ui'
```
Also acceptable when short:
```ts
import { Button, Card } from '@/components/ui'
```
Rule: named-binding `}` is associative-array-style syntax. It must not be alone
at the beginning of a line. Prefer keeping `{` with the first binding and `}`
with the final binding when this fits the line limit.
#### Type literals
Bad:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
}
```
Good:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void }
```
Rule: type-literal `}` is not a block close. It must stay on the same line as
the final property unless that would break the hard line limit.
#### Object literals
Bad:
```ts
const value = {
open,
activeScope,
}
```
Good:
```ts
const value = {
open,
activeScope }
```
Bad:
```ts
const value = useMemo (() => ({
open,
activeScope,
}), [open, activeScope])
```
Good:
```ts
const value = useMemo (() => ({
open,
activeScope }), [open, activeScope])
```
Rule: object-literal `}` is associative-array-style syntax. It must not be on a
line by itself. Keep it with the final property, and keep call `)` off the
beginning of a line.
#### Destructuring parameters
Bad:
```tsx
const Component: FC<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 ## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, - ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
+9 -136
ファイルの表示
@@ -1,31 +1,16 @@
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import { BrowserRouter, import { BrowserRouter,
Navigate, Navigate,
Route, Route,
Routes, Routes,
useLocation } from 'react-router-dom' useLocation } from 'react-router-dom'
import DevModeWatermark from '@/components/DevModeWatermark'
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider' import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { applyClientAnimationMode,
applyClientAppearance,
type ClientAnimationMode,
fetchUserThemeSlots,
fetchUserSettings,
getClientThemeMode,
hasStoredClientThemeSelection,
normaliseUserThemeSlots,
setCachedUserThemeSlots,
seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
@@ -62,51 +47,11 @@ import type { Dispatch, FC, SetStateAction } from 'react'
import type { User } from '@/types' import type { User } from '@/types'
const RouteTransitionWrapper = ({ animationMode, user, setUser }: { const RouteTransitionWrapper = ({ user, setUser }: {
animationMode: ClientAnimationMode
user: User | null user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => { setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation () const location = useLocation ()
if (animationMode === 'off')
{
return (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
}
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<Routes location={location}> <Routes location={location}>
@@ -157,37 +102,6 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const App: FC = () => { const App: FC = () => {
const [user, setUser] = useState<User | null> (null) const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200) const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
const appLayoutTransition = useMemo (
() => (
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
),
[animationMode],
)
useEffect (() => {
applyClientAppearance ()
const mediaQuery = window.matchMedia ('(prefers-color-scheme: dark)')
const handleThemeChange = () => {
if (getClientThemeMode () === 'system')
applyClientAppearance ()
}
mediaQuery.addEventListener ('change', handleThemeChange)
return () => mediaQuery.removeEventListener ('change', handleThemeChange)
}, [])
useEffect (() => {
applyClientAnimationMode (animationMode)
}, [animationMode])
useEffect (() => { useEffect (() => {
const createUser = async () => { const createUser = async () => {
@@ -222,30 +136,6 @@ const App: FC = () => {
createUser () createUser ()
}, []) }, [])
useEffect (() => {
if (!(user))
return
void (async () => {
try
{
const [settings, themeSlots] = await Promise.all ([
fetchUserSettings (),
fetchUserThemeSlots (),
])
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
setCachedUserThemeSlots (storedThemeSlots)
if (!(hasStoredClientThemeSelection ()))
seedClientThemeMode (settings.theme)
applyClientAppearance ()
}
catch
{
return
}
}) ()
}, [user])
switch (status) switch (status)
{ {
case 503: case 503:
@@ -255,37 +145,20 @@ const App: FC = () => {
return ( return (
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<BrowserRouter> <BrowserRouter>
<DialogueProvider> <DialogueProvider>
<UnsavedChangesGuardProvider> <LayoutGroup>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutWrapper>
<motion.div <motion.div
layout={animationMode === 'off' ? false : 'position'} layout="position"
transition={{ layout: appLayoutTransition }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="relative flex flex-col h-dvh w-full overflow-y-hidden"> className="flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/> <TopNav user={user}/>
<RouteTransitionWrapper <RouteTransitionWrapper user={user} setUser={setUser}/>
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div> </motion.div>
</LayoutWrapper> </LayoutGroup>
</MotionConfig>
<Toaster/> <Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider> </DialogueProvider>
</BrowserRouter> </BrowserRouter>
</>) </>)
-46
ファイルの表示
@@ -1,46 +0,0 @@
import nikumaru from '@/assets/fonts/nikumaru.otf'
import type { FC } from 'react'
const ROW_COUNT = 8
const COLUMN_COUNT = 12
const DevModeWatermark: FC = () => {
return (
<div
aria-hidden="true"
className="pointer-events-none select-none fixed inset-0 overflow-hidden z-0">
<style>{`
@font-face {
font-family: 'Nikumaru';
src: url(${nikumaru}) format('opentype');
}
`}</style>
<div className="absolute -inset-32 flex flex-col justify-center gap-12 py-48">
{Array.from ({ length: ROW_COUNT }, (_, rowIndex) => (
<div
key={rowIndex}
className={
'flex min-h-32 items-center gap-12 '
+ (rowIndex % 2 === 0 ? 'translate-x-0' : 'translate-x-32')
}>
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
<span
key={columnIndex}
className={
'whitespace-nowrap text-3xl font-bold '
+ 'tracking-[0.3em] text-neutral-950/5 '
+ 'dark:text-white/10'
}
style={{ fontFamily: 'Nikumaru' }}>
</span>))}
</div>))}
</div>
</div>)
}
export default DevModeWatermark
+3 -16
ファイルの表示
@@ -4,8 +4,6 @@ import { motion } from 'framer-motion'
import { useRef } from 'react' import { useRef } from 'react'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MutableRefObject } from 'react' import type { CSSProperties, FC, MutableRefObject } from 'react'
@@ -22,12 +20,6 @@ type Props = {
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const dndId = `tag-node:${ pathKey }` const dndId = `tag-node:${ pathKey }`
const downPosRef = useRef<{ x: number; y: number } | null> (null) const downPosRef = useRef<{ x: number; y: number } | null> (null)
@@ -95,17 +87,12 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
setDropRef (node) setDropRef (node)
}} }}
style={style} style={style}
className={cn ( className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
'min-w-0 max-w-full overflow-hidden rounded select-none',
over && 'ring-2 ring-offset-2')}
{...attributes} {...attributes}
{...listeners}> {...listeners}>
<motion.div <motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden" transition={{ layout: { duration: .2, ease: 'easeOut' } }}
transition={{ layout: layoutTransition }} layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
layoutId={animationMode === 'off'
? undefined
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
<TagLink tag={tag} nestLevel={nestLevel}/> <TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div> </motion.div>
</div>) </div>)
+4 -10
ファイルの表示
@@ -318,20 +318,12 @@ const MobileMaterialTreeNode: FC<{ depth?: number
<TagLink <TagLink
tag={sidebarTagToTag (tag)} tag={sidebarTagToTag (tag)}
title={tag.name} title={tag.name}
truncateOnMobile
withCount={false} withCount={false}
withWiki={false} withWiki={false}
to={materialPath (tag.id, materialFilter)} to={materialPath (tag.id, materialFilter)}
className="block overflow-hidden text-ellipsis whitespace-nowrap className="block overflow-hidden text-ellipsis whitespace-nowrap
[max-inline-size:var(--tag-link-inline-size)] [max-inline-size:var(--tag-link-inline-size)]
[max-height:var(--tag-link-inline-size)] [max-height:var(--tag-link-inline-size)]"/>
[&_.tag-marquee]:block
[&_.tag-marquee]:h-full
[&_.tag-marquee]:overflow-hidden
[&_.tag-marquee__static]:h-full
[&_.tag-marquee__static]:overflow-hidden
[&_.tag-marquee__static]:text-ellipsis
[&_.tag-marquee__static]:[text-overflow:ellipsis]"/>
</div> </div>
{tag.hasChildren && ( {tag.hasChildren && (
<button <button
@@ -488,7 +480,8 @@ const MaterialSidebar: FC = () => {
</div> </div>
</div> </div>
<SidebarComponent sidebarKey="materials" className="hidden md:block"> <div className="hidden md:block">
<SidebarComponent>
<div className="space-y-4"> <div className="space-y-4">
<MaterialFilterButtons <MaterialFilterButtons
materialFilter={materialFilter} materialFilter={materialFilter}
@@ -505,6 +498,7 @@ const MaterialSidebar: FC = () => {
</ul>)} </ul>)}
</div> </div>
</SidebarComponent> </SidebarComponent>
</div>
</>) </>)
} }
-23
ファイルの表示
@@ -66,27 +66,4 @@ describe ('PostEditForm', () => {
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
}) })
it ('preserves duration while the video tag is temporarily removed', () => {
const post = buildPost ({
videoMs: 180_500,
tags: [
buildTag ({ id: 1, name: '動画', category: 'general' }),
buildTag ({ id: 2, name: 'general-tag', category: 'general' }),
],
})
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
})
}) })
+10 -41
ファイルの表示
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useState } from 'react'
import PostFormTagsArea from '@/components/PostFormTagsArea' import PostFormTagsArea from '@/components/PostFormTagsArea'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
@@ -9,35 +9,29 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
import { inputClass, msToTime } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types' import type { Post, Tag } from '@/types'
type PostFormField = type PostFormField =
'parentPostIds' | 'tags' | 'videoMs' | 'originalCreatedAt' 'parentPostIds' | 'tags' | 'originalCreatedAt'
const videoMsToDurationValue = (videoMs: number | null): string =>
videoMs == null ? '' : String (videoMs / 1_000)
const tagsToStr = (tags: TagWithSections[]): string => { const tagsToStr = (tags: Tag[]): string => {
const result: Omit<TagWithSections, 'children'>[] = [] const result: Tag[] = []
const walk = (tag: TagWithSections) => { const walk = (tag: Tag) => {
const { children, ...rest } = tag const { children, ...rest } = tag
result.push (rest) result.push (rest)
children.forEach (walk) children?.forEach (walk)
} }
tags.filter (t => t.category !== 'nico').forEach (walk) tags.filter (t => t.category !== 'nico').forEach (walk)
return [...(new Set (result.map (t => return [...(new Set (result.map (t => t.name)))].join (' ')
`${ t.name }${ t.sections
.map (s => `[${ msToTime (s.beginMs) }-${ s.endMs == null ? '' : msToTime (s.endMs) }]`)
.join ('') }`)))].join (' ')
} }
@@ -47,7 +41,6 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => { const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [disabled, setDisabled] = useState (false) const [disabled, setDisabled] = useState (false)
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> () useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] =
@@ -59,10 +52,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const [tags, setTags] = useState<string> ('') const [tags, setTags] = useState<string> ('')
const [title, setTitle] = useState (post.title) const [title, setTitle] = useState (post.title)
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const dialogue = useDialogue () const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => { const update = async (...args: Parameters<typeof updatePost>) => {
@@ -74,7 +63,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
onSave ({ ...post, onSave ({ ...post,
versionNo: data.versionNo, versionNo: data.versionNo,
title: data.title, title: data.title,
videoMs: data.videoMs,
tags: data.tags, tags: data.tags,
parentPosts: data.parentPosts, parentPosts: data.parentPosts,
childPosts: data.childPosts, childPosts: data.childPosts,
@@ -114,7 +102,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{ {
// TODO: 差分 UI // TODO: 差分 UI
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, merge: true }) { baseVersionNo: post.versionNo, merge: true })
return return
@@ -123,7 +110,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
if (action === 'overwrite') if (action === 'overwrite')
{ {
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo, force: true }) { baseVersionNo: post.versionNo, force: true })
return return
@@ -138,7 +124,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
try try
{ {
await update ({ id: post.id, title, tags, parentPostIds, await update ({ id: post.id, title, tags, parentPostIds,
duration: videoFlg ? duration : null,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo }) { baseVersionNo: post.versionNo })
} }
@@ -150,7 +135,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
useEffect (() => { useEffect (() => {
setTags(tagsToStr (post.tags)) setTags(tagsToStr (post.tags))
setDuration (videoMsToDurationValue (post.videoMs))
}, [post]) }, [post])
return ( return (
@@ -165,7 +149,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
disabled={disabled} disabled={disabled}
className={inputClass (invalid)} className={inputClass (invalid)}
value={title ?? ''} value={title ?? ''}
onChange={e => setTitle (e.target.value)}/>)} onChange={ev => setTitle (ev.target.value)}/>)}
</FormField> </FormField>
{/* 親投稿 */} {/* 親投稿 */}
@@ -197,20 +181,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{videoFlg && (
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
disabled={disabled}
className={inputClass (invalid)}
value={duration}
onChange={e => setDuration (e.target.value)}/>)}
</FormField>)}
{/* 送信 */} {/* 送信 */}
<Button type="submit" disabled={disabled}> <Button type="submit" disabled={disabled}>
@@ -218,5 +188,4 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
</form>) </form>)
} }
export default PostEditForm export default PostEditForm
+5 -56
ファイルの表示
@@ -4,8 +4,6 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed' import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import type { FC, RefObject } from 'react' import type { FC, RefObject } from 'react'
@@ -26,19 +24,16 @@ type Props = {
onError?: (data: unknown) => void } onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ( const PostEmbed: FC<Props> = ({
{ ref, ref,
post, post,
onLoadComplete, onLoadComplete,
onMetadataChange, onMetadataChange,
onVideoReady, onVideoReady,
onPlaybackChange, onPlaybackChange,
onError }, onError,
) => { }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const behaviourSettings = useClientBehaviourSettings ()
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
const [manualLoadRequested, setManualLoadRequested] = useState (false)
const [framed, setFramed] = useState (false) const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null) const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false) const niconicoVideoReadyRef = useRef (false)
@@ -110,10 +105,6 @@ const PostEmbed: FC<Props> = (
niconicoVideoReadyRef.current = false niconicoVideoReadyRef.current = false
}, [post.url]) }, [post.url])
useEffect (() => {
setManualLoadRequested (false)
}, [embedAutoLoadMode, post.url])
useEffect (() => { useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange)) if (!(youtubePlayer) || !(onPlaybackChange))
return return
@@ -126,28 +117,6 @@ const PostEmbed: FC<Props> = (
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer]) }, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url) 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 ('.')) switch (url.hostname.split ('.').slice (-2).join ('.'))
{ {
@@ -159,11 +128,6 @@ const PostEmbed: FC<Props> = (
const [videoId] = mVideoId const [videoId] = mVideoId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return ( return (
<NicoViewer <NicoViewer
ref={ref} ref={ref}
@@ -186,11 +150,6 @@ const PostEmbed: FC<Props> = (
const [userId] = mUserId const [userId] = mUserId
const [statusId] = mStatusId const [statusId] = mStatusId
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return <TwitterEmbed userId={userId} statusId={statusId}/> return <TwitterEmbed userId={userId} statusId={statusId}/>
} }
@@ -200,11 +159,6 @@ const PostEmbed: FC<Props> = (
if (!(videoId)) if (!(videoId))
break break
if (embedAutoLoadMode === 'off')
return externalLink
if (shouldLoadEmbed === false)
return manualLoadControl
return ( return (
<YoutubeEmbed videoId={videoId} opts={{ playerVars: { <YoutubeEmbed videoId={videoId} opts={{ playerVars: {
playsinline: 1, playsinline: 1,
@@ -230,11 +184,6 @@ const PostEmbed: FC<Props> = (
height={360}/>) height={360}/>)
: ( : (
<div> <div>
{embedAutoLoadMode === 'off'
? externalLink
: embedAutoLoadMode === 'manual' && shouldLoadEmbed === false
? manualLoadControl
: (
<a href="#" onClick={async e => { <a href="#" onClick={async e => {
e.preventDefault () e.preventDefault ()
@@ -248,7 +197,7 @@ const PostEmbed: FC<Props> = (
confirmText: '表示' })) confirmText: '表示' }))
}}> }}>
</a>)} </a>
</div>)} </div>)}
</>) </>)
} }
+3 -6
ファイルの表示
@@ -68,12 +68,9 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
setBounds ({ start, end }) setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token, nico: '0' } })
q: token, setSuggestions (data.filter (t => t.postCount > 0))
nico: '0' } }) setSuggestionsVsbl (suggestions.length > 0)
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
} }
return ( return (
+5 -30
ファイルの表示
@@ -3,7 +3,6 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -17,15 +16,6 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => { const PostList: FC<Props> = ({ posts, onClick }) => {
const location = useLocation () const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
const animationMode = behaviourSettings.animation ?? 'normal'
const cardLayoutTransition =
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
@@ -49,8 +39,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
}}> }}>
<motion.div <motion.div
ref={cardRef} ref={cardRef}
layout={animationMode === 'off' ? false : true} layoutId={layoutId}
layoutId={animationMode === 'off' ? undefined : layoutId}
className={cn ('w-full h-full overflow-hidden rounded-xl shadow', className={cn ('w-full h-full overflow-hidden rounded-xl shadow',
'transform-gpu will-change-transform', 'transform-gpu will-change-transform',
(post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500', (post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500',
@@ -70,27 +59,13 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
cardRef.current.style.zIndex = '' cardRef.current.style.zIndex = ''
cardRef.current.style.position = '' cardRef.current.style.position = ''
}} }}
transition={{ layout: cardLayoutTransition }}> transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
{thumbnailMode === 'off' <img src={post.thumbnail || post.thumbnailBase || undefined}
? (
<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} alt={post.title || post.url}
title={post.title || post.url || undefined} title={post.title || post.url || undefined}
loading={ loading={i < 12 ? 'eager' : 'lazy'}
thumbnailMode === 'light'
? 'lazy'
: i < 12
? 'eager'
: 'lazy'
}
decoding="async" decoding="async"
className="object-cover w-full h-full"/>)} className="object-cover w-full h-full"/>
</motion.div> </motion.div>
</PrefetchLink>) </PrefetchLink>)
})} })}
+4 -27
ファイルの表示
@@ -5,8 +5,6 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -35,20 +33,10 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
[to],
)
const url = useMemo (() => { const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString () const path = (typeof to === 'string') ? to : createPath (to)
}, [path]) return (new URL (path, location.origin)).toString ()
const nextPathname = useMemo ( }, [to])
() => (new URL (path, window.location.origin)).pathname,
[path],
)
const setOverlay = useOverlayStore (s => s.setActive) const setOverlay = useOverlayStore (s => s.setActive)
const doPrefetch = async () => { const doPrefetch = async () => {
@@ -66,15 +54,11 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => { const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => {
onMouseEnter?.(ev) onMouseEnter?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch () await doPrefetch ()
} }
const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => { const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => {
onTouchStart?.(ev) onTouchStart?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch () await doPrefetch ()
} }
@@ -93,13 +77,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
ev.preventDefault () ev.preventDefault ()
if (nextPathname !== window.location.pathname)
{
const confirmed = await confirmDiscardNavigation ()
if (!(confirmed))
return
}
flushSync (() => { flushSync (() => {
setOverlay (true) setOverlay (true)
}) })
@@ -122,7 +99,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
return ( return (
<a ref={ref} <a ref={ref}
href={path} href={typeof to === 'string' ? to : createPath (to)}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onClick={handleClick} onClick={handleClick}
-254
ファイルの表示
@@ -1,254 +0,0 @@
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react'
import type { FC } from 'react'
type Props = {
text: string
className?: string
truncateOnMobile?: boolean
title?: string }
const DESKTOP_MARQUEE_MEDIA =
'(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)'
const MARQUEE_START_DELAY_MS = 1600
const MARQUEE_END_HOLD_MS = 2400
const MARQUEE_SCROLL_PX_PER_SECOND = 43
const MIN_MARQUEE_OVERFLOW_PX = 1
const ResponsiveMarqueeText: FC<Props> = (
{ text, className, truncateOnMobile = false, title },
) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const outerRef = useRef<HTMLSpanElement | null> (null)
const staticRef = useRef<HTMLSpanElement | null> (null)
const animatedRef = useRef<HTMLSpanElement | null> (null)
const animationRef = useRef<Animation | null> (null)
const timeoutRef = useRef<number | null> (null)
const rafRef = useRef<number | null> (null)
const [overflowPx, setOverflowPx] = useState (0)
const [desktopMarqueeEnabled, setDesktopMarqueeEnabled] = useState (false)
const [active, setActive] = useState (false)
const [marqueeVisible, setMarqueeVisible] = useState (false)
useEffect (() => {
const outer = outerRef.current
const inner = staticRef.current
if (!(outer) || !(inner))
return
const measure = () => {
const nextOverflow = Math.max (0, Math.ceil (inner.scrollWidth - outer.clientWidth))
setOverflowPx (prev => Math.abs (prev - nextOverflow) <= 1 ? prev : nextOverflow)
}
measure ()
const resizeObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver (() => {
measure ()
})
resizeObserver?.observe (outer)
resizeObserver?.observe (inner)
addEventListener ('resize', measure)
return () => {
resizeObserver?.disconnect ()
removeEventListener ('resize', measure)
}
}, [text])
useEffect (() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
return
const media = window.matchMedia (DESKTOP_MARQUEE_MEDIA)
const update = () => {
setDesktopMarqueeEnabled (media.matches)
}
update ()
media.addEventListener ('change', update)
return () => {
media.removeEventListener ('change', update)
}
}, [])
useEffect (() => {
const clearScheduled = () => {
if (timeoutRef.current != null)
{
clearTimeout (timeoutRef.current)
timeoutRef.current = null
}
if (rafRef.current != null)
{
cancelAnimationFrame (rafRef.current)
rafRef.current = null
}
animationRef.current?.cancel ()
animationRef.current = null
}
const animated = animatedRef.current
const canMarquee = (
active
&& animationMode !== 'off'
&& desktopMarqueeEnabled
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX
&& animated != null)
const resetAnimated = () => {
const node = animatedRef.current
animationRef.current?.cancel ()
animationRef.current = null
node?.getAnimations?.().forEach (animation => {
animation.cancel ()
})
if (!(node))
return
node.style.transition = 'none'
node.style.transform = 'translateX(0)'
}
if (!canMarquee)
{
clearScheduled ()
resetAnimated ()
setMarqueeVisible (false)
return
}
let cancelled = false
const sleep = (ms: number) =>
new Promise<void> (resolve => {
timeoutRef.current = window.setTimeout (() => {
timeoutRef.current = null
resolve ()
}, ms)
})
const runLoop = async () => {
while (!cancelled)
{
resetAnimated ()
setMarqueeVisible (true)
await sleep (MARQUEE_START_DELAY_MS)
if (cancelled || !(animatedRef.current))
break
const moveDurationMs =
overflowPx / MARQUEE_SCROLL_PX_PER_SECOND * 1000
const node = animatedRef.current
if (typeof node.animate === 'function')
{
const animation = node.animate (
[
{ transform: 'translateX(0)' },
{ transform: `translateX(-${ overflowPx }px)` },
],
{ duration: moveDurationMs, easing: 'linear', fill: 'forwards' })
animationRef.current = animation
try
{
await animation.finished
}
catch
{
break
}
node.style.transform = `translateX(-${ overflowPx }px)`
animation.cancel ()
if (animationRef.current === animation)
animationRef.current = null
}
else
{
node.style.transition = `transform ${ moveDurationMs }ms linear`
await new Promise<void> (resolve => {
rafRef.current = requestAnimationFrame (() => {
rafRef.current = null
node.style.transform = `translateX(-${ overflowPx }px)`
resolve ()
})
})
await sleep (moveDurationMs)
node.style.transition = 'none'
node.style.transform = `translateX(-${ overflowPx }px)`
}
if (cancelled)
break
await sleep (MARQUEE_END_HOLD_MS)
if (cancelled)
break
resetAnimated ()
}
}
void runLoop ()
return () => {
cancelled = true
clearScheduled ()
resetAnimated ()
setMarqueeVisible (false)
}
}, [active, animationMode, desktopMarqueeEnabled, overflowPx, text])
return (
<span
ref={outerRef}
title={title ?? text}
onMouseEnter={() => setActive (true)}
onMouseLeave={() => setActive (false)}
onFocus={() => setActive (true)}
onBlur={() => setActive (false)}
className={cn (
'tag-marquee inline-block max-w-full min-w-0 align-bottom',
(truncateOnMobile
? 'overflow-hidden text-ellipsis whitespace-nowrap'
: 'whitespace-normal [overflow-wrap:anywhere]'),
'md:overflow-hidden md:whitespace-nowrap',
className)}>
<span
ref={staticRef}
className={cn (
'tag-marquee__static block max-w-full',
(truncateOnMobile
? 'overflow-hidden text-ellipsis whitespace-nowrap'
: 'whitespace-normal [overflow-wrap:anywhere]'),
'md:overflow-hidden md:text-ellipsis md:whitespace-nowrap',
marqueeVisible && 'md:opacity-0')}>
{text}
</span>
{overflowPx >= MIN_MARQUEE_OVERFLOW_PX && (
<span
ref={animatedRef}
aria-hidden="true"
className={cn (
'tag-marquee__animated hidden md:block',
marqueeVisible ? 'md:opacity-100' : 'md:opacity-0')}>
{text}
</span>)}
</span>)
}
export default ResponsiveMarqueeText
+20 -100
ファイルの表示
@@ -20,21 +20,19 @@ import SidebarComponent from '@/components/layout/SidebarComponent'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils' import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core' import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react' import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, TagWithSections } from '@/types' import type { Category, Post, Tag } from '@/types'
type TagByCategory = { [key in Category]: TagWithSections[] } type TagByCategory = { [key in Category]: Tag[] }
const renderTagTree = ( const renderTagTree = (
tag: TagWithSections, tag: Tag,
nestLevel: number, nestLevel: number,
path: string, path: string,
suppressClickRef: MutableRefObject<boolean>, suppressClickRef: MutableRefObject<boolean>,
@@ -62,10 +60,10 @@ const renderTagTree = (
?? [])] ?? [])]
} }
const isDescendant = ( const isDescendant = (
root: TagWithSections, root: Tag,
targetId: number, targetId: number): boolean => {
): boolean => {
if (!(root.children)) if (!(root.children))
return false return false
@@ -83,9 +81,8 @@ const isDescendant = (
const findTag = ( const findTag = (
byCat: TagByCategory, byCat: TagByCategory,
id: number, id: number): Tag | undefined => {
): TagWithSections | undefined => { const walk = (nodes: Tag[]): Tag | undefined => {
const walk = (nodes: TagWithSections[]): TagWithSections | undefined => {
for (const t of nodes) for (const t of nodes)
{ {
if (t.id === id) if (t.id === id)
@@ -128,37 +125,6 @@ const buildTagByCategory = (post: Post): TagByCategory => {
} }
const buildFlatTagByCategory = (
byCategory: TagByCategory,
): TagByCategory => {
const tagsTmp = { } as TagByCategory
const seen = new Set<number> ()
for (const category of CATEGORIES)
tagsTmp[category] = []
const visit = (tag: TagWithSections) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
tagsTmp[tag.category].push ({ ...tag, children: [] })
for (const child of tag.children ?? [])
visit (child)
}
for (const category of CATEGORIES)
for (const tag of byCategory[category] ?? [])
visit (tag)
for (const category of CATEGORIES)
tagsTmp[category].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1)
return tagsTmp
}
const changeCategory = async ( const changeCategory = async (
tagId: number, tagId: number,
category: Category): Promise<void> => { category: Category): Promise<void> => {
@@ -178,23 +144,13 @@ const DropSlot = ({ cat }: { cat: Category }) => {
} }
type Props = { type Props = { post: Post; sp?: boolean }
className?: string
post: Post
sp?: boolean }
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => { const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
sp = Boolean (sp) sp = Boolean (sp)
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped'
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const baseTags = useMemo<TagByCategory> (() => { const baseTags = useMemo<TagByCategory> (() => {
const tagsTmp = { } as TagByCategory const tagsTmp = { } as TagByCategory
@@ -207,7 +163,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
} }
for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[])
tagsTmp[cat].sort ((tagA: TagWithSections, tagB: TagWithSections) => tagA.name < tagB.name ? -1 : 1) tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1)
return tagsTmp return tagsTmp
}, [post]) }, [post])
@@ -216,10 +172,6 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
const [dragging, setDragging] = useState (false) const [dragging, setDragging] = useState (false)
const [saving, setSaving] = useState (false) const [saving, setSaving] = useState (false)
const [tags, setTags] = useState (baseTags) const [tags, setTags] = useState (baseTags)
const flatTagsByCategory = useMemo<TagByCategory> (
() => buildFlatTagByCategory (tags),
[tags],
)
const suppressClickRef = useRef (false) const suppressClickRef = useRef (false)
@@ -324,7 +276,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
}, [baseTags]) }, [baseTags])
return ( return (
<SidebarComponent sidebarKey="post-detail-tags" className={className}> <SidebarComponent>
<TagSearch/> <TagSearch/>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -353,58 +305,26 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
document.body.style.userSelect = '' document.body.style.userSelect = ''
}} }}
modifiers={[restrictToWindowEdges]}> modifiers={[restrictToWindowEdges]}>
{CATEGORIES.map ((cat: Category) => { {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
const categoryTags =
tagRelationDisplay === 'grouped'
? (tags[cat] ?? [])
: (flatTagsByCategory[cat] ?? [])
if (!(categoryTags.length > 0 || dragging))
return null
return (
<div className="my-3" key={cat}> <div className="my-3" key={cat}>
<SubsectionTitle> <SubsectionTitle>
<motion.div <motion.div
layoutId={animationMode === 'off' layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}
? undefined transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
: `tag-${ sp ? 'sp-' : '' }${ cat }`}
transition={{ layout: layoutTransition }}>
{CATEGORY_NAMES[cat]} {CATEGORY_NAMES[cat]}
</motion.div> </motion.div>
</SubsectionTitle> </SubsectionTitle>
<ul> <ul>
{(tagRelationDisplay === 'grouped' {(tags[cat] ?? []).flatMap (tag => (
? (tags[cat] ?? []).flatMap (tag => renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))}
renderTagTree (
tag,
0,
`cat-${ cat }`,
suppressClickRef,
undefined,
sp,
),
)
: (flatTagsByCategory[cat] ?? []).map (tag => (
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
<DraggableDroppableTagRow
tag={tag}
nestLevel={0}
pathKey={`flat-${ cat }-${ tag.id }`}
suppressClickRef={suppressClickRef}
sp={sp}/>
</li>)))}
<DropSlot cat={cat}/> <DropSlot cat={cat}/>
</ul> </ul>
</div>) </div>))}
})}
{post && ( {post && (
<motion.div <motion.div
layoutId={animationMode === 'off' layoutId={`post-info-${ sp }`}
? undefined transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
: `post-info-${ sp }`}
transition={{ layout: layoutTransition }}>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
<ul> <ul>
<li>Id.: {post.id}</li> <li>Id.: {post.id}</li>
+24 -56
ファイルの表示
@@ -1,15 +1,14 @@
import PrefetchLink from '@/components/PrefetchLink' 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 { cn } from '@/lib/utils'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react' import type { ComponentProps, FC, HTMLAttributes } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
type CommonProps = { type CommonProps = {
tag: Tag tag: Tag
nestLevel?: number nestLevel?: number
truncateOnMobile?: boolean
withWiki?: boolean withWiki?: boolean
withCount?: boolean } withCount?: boolean }
@@ -31,40 +30,29 @@ type Props =
const TagLink: FC<Props> = ({ tag, const TagLink: FC<Props> = ({ tag,
nestLevel = 0, nestLevel = 0,
linkFlg = true, linkFlg = true,
truncateOnMobile = false,
withWiki = true, withWiki = true,
withCount = true, withCount = true,
className, className,
style,
title,
...props }) => { ...props }) => {
const colourStyle = { const spanClass = cn (
'--tag-link-colour': `var(--tag-colour-${ tag.category })`, `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`, `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
...style, const linkClass = cn (
} as CSSProperties spanClass,
const spanClass = 'tag-link-colour' `hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
const linkClass = 'tag-link-colour tag-link-hover-colour' `dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
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'
const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto'
const textTitle = title
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias }${ tag.name }`)
return ( return (
<span className={rootClass}> <>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki) && (
<span className={markerWrapClass}> <span className="mr-1">
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
? ( ? (
tag.materialId == null && !(tag.hasDeerjikists) tag.materialId == null && !(tag.hasDeerjikists)
? ( ? (
<PrefetchLink <PrefetchLink
to={`/wiki/${ encodeURIComponent (tag.name) }`} to={`/wiki/${ encodeURIComponent (tag.name) }`}
className={linkClass} className={linkClass}>
style={colourStyle}>
? ?
</PrefetchLink>) </PrefetchLink>)
: ( : (
@@ -72,15 +60,13 @@ const TagLink: FC<Props> = ({ tag,
? ( ? (
<PrefetchLink <PrefetchLink
to={`/materials/${ tag.materialId }`} to={`/materials/${ tag.materialId }`}
className={linkClass} className={linkClass}>
style={colourStyle}>
? ?
</PrefetchLink>) </PrefetchLink>)
: ( : (
<PrefetchLink <PrefetchLink
to={`/tags/${ tag.id }/deerjikists`} to={`/tags/${ tag.id }/deerjikists`}
className={linkClass} className={linkClass}>
style={colourStyle}>
? ?
</PrefetchLink>))) </PrefetchLink>)))
: ( : (
@@ -114,51 +100,33 @@ const TagLink: FC<Props> = ({ tag,
</span>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span
className="ml-1 mr-1 shrink-0" className="ml-1 mr-1"
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}> style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
</span>)} </span>)}
{tag.matchedAlias != null && ( {tag.matchedAlias != null && (
<> <>
<span <span className={cn (spanClass, className)} {...props}>
title={textTitle} {tag.matchedAlias}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}>
<ResponsiveMarqueeText
text={tag.matchedAlias}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span> </span>
<span className="shrink-0"> </span> <> </>
</>)} </>)}
{linkFlg {linkFlg
? ( ? (
<PrefetchLink <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
title={textTitle} className={cn (linkClass, className)}
className={cn (linkClass, textClass, className)}
style={colourStyle}
{...props}> {...props}>
<ResponsiveMarqueeText {tag.name}
text={tag.name}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</PrefetchLink>) </PrefetchLink>)
: ( : (
<span <span className={cn (spanClass, className)}
title={textTitle}
className={cn (spanClass, textClass, className)}
style={colourStyle}
{...props}> {...props}>
<ResponsiveMarqueeText {tag.name}
text={tag.name}
title={textTitle}
truncateOnMobile={truncateOnMobile}/>
</span>)} </span>)}
{withCount && ( {withCount && (
<span className={countClass}>{tag.postCount}</span>)} <span className="ml-1">{tag.postCount}</span>)}
</span>) </>)
} }
export default TagLink export default TagLink
-1
ファイルの表示
@@ -105,7 +105,6 @@ const TagSearch: FC = () => {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<input type="text" <input type="text"
data-shortcut-focus-search="true"
placeholder="タグ検索..." placeholder="タグ検索..."
value={search} value={search}
onChange={whenChanged} onChange={whenChanged}
+1 -3
ファイルの表示
@@ -20,12 +20,10 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
rounded shadow"> rounded shadow">
{suggestions.map ((tag, i) => ( {suggestions.map ((tag, i) => (
<li key={tag.id} <li key={tag.id}
className={cn ('min-w-0 overflow-hidden px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700', className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')} i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
onMouseDown={() => onSelect (tag)}> onMouseDown={() => onSelect (tag)}>
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
<TagLink tag={tag} linkFlg={false} withWiki={false}/> <TagLink tag={tag} linkFlg={false} withWiki={false}/>
</div>
</li>))} </li>))}
</ul>) </ul>)
} }
+6 -29
ファイルの表示
@@ -8,8 +8,6 @@ import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
@@ -23,13 +21,6 @@ type Props = { posts: Post[]
const TagSidebar: FC<Props> = ({ posts, onClick }) => { const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const navigate = useNavigate () const navigate = useNavigate ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const [tagsVsbl, setTagsVsbl] = useState (false) const [tagsVsbl, setTagsVsbl] = useState (false)
const [tags, setTags] = useState<TagByCategory> ({ }) const [tags, setTags] = useState<TagByCategory> ({ })
@@ -73,19 +64,12 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
<ul> <ul>
{CATEGORIES.flatMap (cat => cat in tags ? ( {CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => ( tags[cat].map (tag => (
<li key={tag.id} className="mb-1 min-w-0 max-w-full overflow-hidden"> <li key={tag.id} className="mb-1">
{animationsOff
? (
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
<TagLink tag={tag} onClick={onClick}/>
</div>)
: (
<motion.div <motion.div
className="flex min-w-0 max-w-full items-baseline overflow-hidden" transition={{ layout: { duration: .2, ease: 'easeOut' } }}
transition={{ layout: layoutTransition }}
layoutId={`tag-${ tag.id }`}> layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/> <TagLink tag={tag} onClick={onClick}/>
</motion.div>)} </motion.div>
</li>))) : [])} </li>))) : [])}
</ul> </ul>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
@@ -111,20 +95,13 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</>) </>)
return ( return (
<SidebarComponent sidebarKey="posts-index-tags"> <SidebarComponent>
<TagSearch/> <TagSearch/>
<div className="hidden md:block mt-4"> <div className="hidden md:block mt-4">
{posts.length > 0 && TagBlock} {posts.length > 0 && TagBlock}
</div> </div>
{animationsOff
? (
tagsVsbl && (
<div className="md:hidden overflow-hidden">
{posts.length > 0 && TagBlock}
</div>))
: (
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{tagsVsbl && ( {tagsVsbl && (
<motion.div <motion.div
@@ -133,10 +110,10 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
initial={{ height: 0 }} initial={{ height: 0 }}
animate={{ height: 'auto' }} animate={{ height: 'auto' }}
exit={{ height: 0 }} exit={{ height: 0 }}
transition={layoutTransition}> transition={{ duration: .2, ease: 'easeOut' }}>
{posts.length > 0 && TagBlock} {posts.length > 0 && TagBlock}
</motion.div>)} </motion.div>)}
</AnimatePresence>)} </AnimatePresence>
<a href="#" <a href="#"
className="md:hidden block my-2 text-center text-sm className="md:hidden block my-2 text-center text-sm
+49 -162
ファイルの表示
@@ -8,7 +8,6 @@ import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser' import TopNavUser from '@/components/TopNavUser'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { fetchTag, fetchTagByName } from '@/lib/tags' import { fetchTag, fetchTagByName } from '@/lib/tags'
import { fetchMaterial } from '@/lib/materials' import { fetchMaterial } from '@/lib/materials'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -92,10 +91,6 @@ export const menuOutline = (
const TopNav: FC<Props> = ({ user }) => { const TopNav: FC<Props> = ({ user }) => {
const location = useLocation () const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const reducedAnimations = animationMode === 'reduced'
const dirRef = useRef<(-1) | 1> (1) const dirRef = useRef<(-1) | 1> (1)
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
@@ -160,24 +155,6 @@ const TopNav: FC<Props> = ({ user }) => {
const activeIdx = const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to)) visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40) const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
const topNavTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const opacityTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08 }
: { duration: .12 }
const highlightTransitionClass =
animationsOff
? undefined
: reducedAnimations
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out'
const prevActiveIdxRef = useRef<number> (activeIdx) const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -212,11 +189,13 @@ const TopNav: FC<Props> = ({ user }) => {
return ( return (
<> <>
<nav className="top-nav-root px-3 flex justify-between items-center w-full"> <nav className="px-3 flex justify-between items-center w-full
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<div className="flex items-center gap-2 h-12"> <div className="flex items-center gap-2 h-12">
<PrefetchLink <PrefetchLink
to="/posts" to="/posts"
className="top-nav-brand-link mx-4 text-xl font-bold" className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
onClick={() => { onClick={() => {
scroll (0, 0) scroll (0, 0)
}}> }}>
@@ -226,8 +205,8 @@ const TopNav: FC<Props> = ({ user }) => {
<div ref={navRef} className="relative hidden md:flex h-12 items-center"> <div ref={navRef} className="relative hidden md:flex h-12 items-center">
<div aria-hidden <div aria-hidden
className={cn ('absolute inset-y-0 h-12', className={cn ('absolute inset-y-0 h-12',
'top-nav-highlight', 'bg-yellow-200 dark:bg-red-950',
highlightTransitionClass)} 'transition-[transform,width] duration-200 ease-out')}
style={{ width: hl.width, style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`, transform: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/> opacity: hl.visible ? 1 : 0 }}/>
@@ -235,12 +214,10 @@ const TopNav: FC<Props> = ({ user }) => {
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
<motion.div <motion.div
key={item.to} key={item.to}
{...(animationsOff layoutId={`menu-${ item.name }`}
? { }
: { layoutId: `menu-${ item.name }` })}
animate={{ opacity: moreVsbl ? 0 : 1 }} animate={{ opacity: moreVsbl ? 0 : 1 }}
transition={{ opacity: opacityTransition, transition={{ opacity: { duration: .12 },
layout: topNavTransition }} layout: { duration: .2, ease: 'easeOut' } }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }} style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
onMouseEnter={() => setMoreVsbl (false)}> onMouseEnter={() => setMoreVsbl (false)}>
<PrefetchLink <PrefetchLink
@@ -248,7 +225,7 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el itemsRef.current[i] = el
}} }}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5', className={cn ('relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}> (i === openItemIdx) && 'font-bold')}>
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
@@ -263,7 +240,7 @@ const TopNav: FC<Props> = ({ user }) => {
setMoreVsbl (true) setMoreVsbl (true)
measure (-1) measure (-1)
}} }}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5', className={cn ('relative z-10 flex h-full items-center px-5',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}> (openItemIdx < 0 || moreVsbl) && 'font-bold')}>
&raquo; &raquo;
</PrefetchLink> </PrefetchLink>
@@ -272,28 +249,30 @@ const TopNav: FC<Props> = ({ user }) => {
<TopNavUser user={user}/> <TopNavUser user={user}/>
<button <a href="#"
type="button" className="md:hidden ml-auto pr-4
className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4" text-pink-600 hover:text-pink-400
onClick={() => { dark:text-pink-300 dark:hover:text-pink-100"
onClick={ev => {
ev.preventDefault ()
setMenuOpen (!(menuOpen)) setMenuOpen (!(menuOpen))
}}> }}>
{menuOpen ? '×' : 'Menu'} {menuOpen ? '×' : 'Menu'}
</button> </a>
</nav> </nav>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
<motion.div <motion.div
key="submenu-shell" key="submenu-shell"
{...(animationsOff ? { } layout
: { layout: true })} className="relative z-20 hidden md:block overflow-hidden
className="relative z-20 hidden md:block overflow-hidden top-nav-submenu" bg-yellow-200 dark:bg-red-950"
animate={{ height: submenuHeight }} animate={{ height: submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
setMoreVsbl (false) setMoreVsbl (false)
}} }}
transition={{ layout: topNavTransition }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
onAnimationComplete={() => { onAnimationComplete={() => {
measure (moreVsbl ? -1 : activeIdx) measure (moreVsbl ? -1 : activeIdx)
}}> }}>
@@ -303,14 +282,12 @@ const TopNav: FC<Props> = ({ user }) => {
<div key={i} className="relative h-[40px]"> <div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3"> <div className="absolute inset-0 flex items-center px-3">
<motion.div <motion.div
transition={topNavTransition} transition={{ duration: .2, ease: 'easeOut' }}
{...(animationsOff {...((item.visible ?? true)
? { }
: ((item.visible ?? true)
? { layoutId: `menu-${ item.name }` } ? { layoutId: `menu-${ item.name }` }
: { initial: { x: 40, y: -40, opacity: 0 }, : { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 }, animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))} exit: { x: 40, y: -40, opacity: 0 } })}
className="z-10 h-full flex items-center px-3 font-bold w-28"> className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2> <h2>{item.name}</h2>
</motion.div> </motion.div>
@@ -321,34 +298,30 @@ const TopNav: FC<Props> = ({ user }) => {
? ( ? (
<motion.div <motion.div
key={`c-${ i }-${ j }`} key={`c-${ i }-${ j }`}
transition={topNavTransition} transition={{ duration: .2, ease: 'easeOut' }}
{...(animationsOff {...((visibleMenu[activeIdx]?.name
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name) === item.name)
? { layoutId: `submenu-${ item.name }-${ j }` } ? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 }, : { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 }, animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}> exit: { y: -40, opacity: 0 } })}>
{subItem.component} {subItem.component}
</motion.div>) </motion.div>)
: ( : (
<motion.div <motion.div
key={`l-${ i }-${ j }`} key={`l-${ i }-${ j }`}
transition={topNavTransition} transition={{ duration: .2, ease: 'easeOut' }}
{...(animationsOff {...((visibleMenu[activeIdx]?.name
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name) === item.name)
? { layoutId: `submenu-${ item.name }-${ j }` } ? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 }, : { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 }, animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}> exit: { y: -40, opacity: 0 } })}>
<PrefetchLink <PrefetchLink
to={subItem.to} to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined} target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)} onClick={() => setMoreVsbl (false)}
className="top-nav-menu-link h-full flex items-center px-3"> className="h-full flex items-center px-3">
{subItem.name} {subItem.name}
</PrefetchLink> </PrefetchLink>
</motion.div>)))} </motion.div>)))}
@@ -357,30 +330,6 @@ const TopNav: FC<Props> = ({ user }) => {
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0 : ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
&& ( && (
<div className="relative h-[40px]"> <div className="relative h-[40px]">
{animationsOff
? (
<div className="absolute inset-0 flex items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<div key={`c-${ i }`}>
{item.component}
</div>)
: (
<div key={`l-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-menu-link h-full flex items-center px-3">
{item.name}
</PrefetchLink>
</div>)))}
</div>)
: (
<AnimatePresence initial={false} custom={dir}> <AnimatePresence initial={false} custom={dir}>
<motion.div <motion.div
key={activeIdx} key={activeIdx}
@@ -392,7 +341,7 @@ const TopNav: FC<Props> = ({ user }) => {
initial="enter" initial="enter"
animate="centre" animate="centre"
exit="exit" exit="exit"
transition={topNavTransition}> transition={{ duration: .2, ease: 'easeOut' }}>
{(visibleMenu[activeIdx]?.subMenu ?? []) {(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true) .filter (item => item.visible ?? true)
.map ((item, i) => ( .map ((item, i) => (
@@ -400,96 +349,34 @@ const TopNav: FC<Props> = ({ user }) => {
? ( ? (
<motion.div <motion.div
key={`c-${ i }`} key={`c-${ i }`}
transition={{ layout: topNavTransition }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}> layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component} {item.component}
</motion.div>) </motion.div>)
: ( : (
<motion.div <motion.div
key={`l-${ i }`} key={`l-${ i }`}
transition={{ layout: topNavTransition }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}> layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
<PrefetchLink <PrefetchLink
to={item.to} to={item.to}
target={item.to.slice (0, 2) === '//' target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
? '_blank' className="h-full flex items-center px-3">
: undefined}
className="top-nav-menu-link h-full flex items-center px-3">
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
</motion.div>)))} </motion.div>)))}
</motion.div> </motion.div>
</AnimatePresence>)} </AnimatePresence>
</div>))} </div>))}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
{animationsOff
? (
menuOpen && (
<div
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'items-start')}>
<Separator/>
{visibleMenu.map ((item, i) => (
<Fragment key={i}>
<PrefetchLink
to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx)
{
ev.preventDefault ()
setOpenItemIdx (i)
}
}}>
{item.name}
</PrefetchLink>
{i === openItemIdx && (
<div className="top-nav-submenu w-full">
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<PrefetchLink
key={`sp-l-${ i }-${ j }`}
to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</PrefetchLink>)))}
</div>)}
</Fragment>))}
<PrefetchLink
to="/more"
ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el
}}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
</div>))
: (
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{menuOpen && ( {menuOpen && (
<motion.div <motion.div
key="spmenu" key="spmenu"
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden', className={cn ('flex flex-col md:hidden',
'items-start')} 'bg-yellow-200 dark:bg-red-975 items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 }, height: 0 },
open: { clipPath: 'inset(0 0 0% 0)', open: { clipPath: 'inset(0 0 0% 0)',
@@ -497,15 +384,15 @@ const TopNav: FC<Props> = ({ user }) => {
initial="closed" initial="closed"
animate="open" animate="open"
exit="closed" exit="closed"
transition={topNavTransition}> transition={{ duration: .2, ease: 'easeOut' }}>
<Separator/> <Separator/>
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
<Fragment key={i}> <Fragment key={i}>
<PrefetchLink <PrefetchLink
to={i === openItemIdx ? item.to : '#'} to={i === openItemIdx ? item.to : '#'}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8', className={cn ('w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx) ((i === openItemIdx)
&& 'top-nav-mobile-active font-bold'))} && 'font-bold bg-yellow-50 dark:bg-red-950'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => { onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx) if (i !== openItemIdx)
{ {
@@ -520,7 +407,7 @@ const TopNav: FC<Props> = ({ user }) => {
{i === openItemIdx && ( {i === openItemIdx && (
<motion.div <motion.div
key={`sp-sub-${ i }`} key={`sp-sub-${ i }`}
className="top-nav-submenu w-full" className="w-full bg-yellow-50 dark:bg-red-950"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0, height: 0,
opacity: 0 }, opacity: 0 },
@@ -530,7 +417,7 @@ const TopNav: FC<Props> = ({ user }) => {
initial="closed" initial="closed"
animate="open" animate="open"
exit="closed" exit="closed"
transition={topNavTransition}> transition={{ duration: .2, ease: 'easeOut' }}>
{item.subMenu {item.subMenu
.filter (subItem => subItem.visible ?? true) .filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => ( .map ((subItem, j) => (
@@ -546,7 +433,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={subItem.to.slice (0, 2) === '//' target={subItem.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12"> className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name} {subItem.name}
</PrefetchLink>)))} </PrefetchLink>)))}
</motion.div>)} </motion.div>)}
@@ -557,15 +444,15 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el itemsRef.current[visibleMenu.length] = el
}} }}
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8', className={cn ('w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0) ((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}> && 'font-bold bg-yellow-50 dark:bg-red-950'))}>
&raquo; &raquo;
</PrefetchLink> </PrefetchLink>
<TopNavUser user={user} sp/> <TopNavUser user={user} sp/>
<Separator/> <Separator/>
</motion.div>)} </motion.div>)}
</AnimatePresence>)} </AnimatePresence>
</>) </>)
} }
-12
ファイルの表示
@@ -20,16 +20,4 @@ describe ('TabGroup', () => {
expect (screen.getByText ('Alpha')).toBeInTheDocument () expect (screen.getByText ('Alpha')).toBeInTheDocument ()
expect (screen.queryByText ('Beta')).not.toBeInTheDocument () expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
}) })
it ('renders tabs as buttons and shows the right addon', () => {
render (
<TabGroup rightAddon={<span> 20</span>}>
<Tab name="広場">Plaza</Tab>
</TabGroup>,
)
expect (screen.getByRole ('button', { name: '広場' })).toBeInTheDocument ()
expect (screen.queryByRole ('link', { name: '広場' })).not.toBeInTheDocument ()
expect (screen.getByText ('件数 20')).toBeInTheDocument ()
})
}) })
+9 -22
ファイルの表示
@@ -7,15 +7,12 @@ type TabProps = { name: string
init?: boolean init?: boolean
children: React.ReactNode } children: React.ReactNode }
type Props = { type Props = { children: React.ReactNode }
children: React.ReactNode
rightAddon?: React.ReactNode
}
export const Tab = ({ children }: TabProps) => <>{children}</> export const Tab = ({ children }: TabProps) => <>{children}</>
const TabGroup: FC<Props> = ({ children, rightAddon }) => { const TabGroup: FC<Props> = ({ children }) => {
const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[] const tabs = React.Children.toArray (children) as React.ReactElement<TabProps>[]
const [current, setCurrent] = useState<number> (() => { const [current, setCurrent] = useState<number> (() => {
@@ -25,27 +22,17 @@ const TabGroup: FC<Props> = ({ children, rightAddon }) => {
return ( return (
<div className="mt-4"> <div className="mt-4">
<div className="flex items-center justify-between gap-4"> <div className="flex gap-4">
<div className="flex min-w-0 gap-4">
{tabs.map ((tab, i) => ( {tabs.map ((tab, i) => (
<button <a key={i}
key={i} href="#"
type="button" className={cn (i === current && 'font-bold')}
className={cn ( onClick={ev => {
'bg-transparent p-0 text-left text-[hsl(var(--theme-link))] hover:opacity-85', ev.preventDefault ()
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
i === current && 'font-bold',
)}
onClick={() => {
setCurrent (i) setCurrent (i)
}}> }}>
{tab.props.name} {tab.props.name}
</button>))} </a>))}
</div>
{rightAddon && (
<div className="shrink-0">
{rightAddon}
</div>)}
</div> </div>
<div className="mt-2"> <div className="mt-2">
{tabs[current]} {tabs[current]}
+1 -17
ファイルの表示
@@ -26,7 +26,7 @@ describe ('TagInput', () => {
await waitFor (() => { await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith ( expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete', '/tags/autocomplete',
{ params: { q: '虹夏', nico: '0' } }, { params: { q: '虹夏' } },
) )
}) })
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏') expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
@@ -41,20 +41,4 @@ describe ('TagInput', () => {
expect (api.apiGet).not.toHaveBeenCalled () expect (api.apiGet).not.toHaveBeenCalled ()
expect (setValue).toHaveBeenCalledWith (' ') 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' } },
)
})
})
}) })
+5 -14
ファイルの表示
@@ -10,20 +10,13 @@ import type { Tag } from '@/types'
type Props = { type Props = {
includeNico?: boolean
describedBy?: string describedBy?: string
invalid?: boolean invalid?: boolean
value: string value: string
setValue: (value: string) => void } setValue: (value: string) => void }
const TagInput: FC<Props> = ({ const TagInput: FC<Props> = ({ describedBy, invalid, value, setValue }) => {
includeNico = false,
describedBy,
invalid,
value,
setValue,
}) => {
const [activeIndex, setActiveIndex] = useState (-1) const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([]) const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
@@ -39,12 +32,10 @@ const TagInput: FC<Props> = ({
return return
} }
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
q, setSuggestions (data.filter (t => t.postCount > 0))
nico: includeNico ? '1' : '0' } }) if (suggestions.length > 0)
const nextSuggestions = data.filter (t => t.postCount > 0) setSuggestionsVsbl (true)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
} }
// TODO: TagSearch からのコピペのため,共通化を考へる. // TODO: TagSearch からのコピペのため,共通化を考へる.
+3 -14
ファイルの表示
@@ -1,7 +1,5 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
@@ -11,21 +9,12 @@ type Props = {
className?: string } className?: string }
const MainArea: FC<Props> = ({ children, className }) => { const MainArea: FC<Props> = ({ children, className }) => (
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
return (
<motion.main <motion.main
transition={{ layout: layoutTransition }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className={cn ('flex-1 overflow-y-auto p-4', className)} className={cn ('flex-1 overflow-y-auto p-4', className)}
layout={animationMode === 'off' ? false : 'position'}> layout="position">
{children} {children}
</motion.main>) </motion.main>)
}
export default MainArea export default MainArea
-124
ファイルの表示
@@ -1,124 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SidebarComponent from '@/components/layout/SidebarComponent'
const sidebarWidth = (element: Element | null): string =>
(element as HTMLElement).style.getPropertyValue ('--sidebar-width')
const sidebarRoot = (container: HTMLElement): Element =>
container.querySelector ('[style*="--sidebar-width"]')!
const sidebarTree = (props: Partial<Parameters<typeof SidebarComponent>[0]>) => (
<HelmetProvider>
<div data-sidebar-container>
<SidebarComponent sidebarKey="test-sidebar" {...props}>
Sidebar body
</SidebarComponent>
</div>
</HelmetProvider>)
const renderSidebar = (
props: Partial<Parameters<typeof SidebarComponent>[0]> = {},
containerWidth = 800,
) => {
Object.defineProperty (HTMLElement.prototype, 'clientWidth', {
configurable: true,
get () {
return this.hasAttribute ('data-sidebar-container') ? containerWidth : 0
},
})
const rendered = render (sidebarTree (props))
return rendered
}
describe ('SidebarComponent', () => {
beforeEach (() => {
Object.defineProperty (window, 'innerWidth', {
configurable: true,
value: 1_000,
})
Object.defineProperty (HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: vi.fn (),
})
Object.defineProperty (HTMLElement.prototype, 'releasePointerCapture', {
configurable: true,
value: vi.fn (),
})
Object.defineProperty (HTMLElement.prototype, 'hasPointerCapture', {
configurable: true,
value: vi.fn (() => true),
})
vi.spyOn (HTMLElement.prototype, 'setPointerCapture').mockImplementation (() => {
;
})
vi.spyOn (HTMLElement.prototype, 'releasePointerCapture').mockImplementation (() => {
;
})
vi.spyOn (HTMLElement.prototype, 'hasPointerCapture').mockReturnValue (true)
})
it ('uses maxWidth without applying the container main-width reserve twice', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '700')
const onWidthChange = vi.fn ()
const { container } = renderSidebar ({ maxWidth: 700, onWidthChange }, 800)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('700px')
})
expect (onWidthChange).toHaveBeenLastCalledWith (700)
})
it ('resets width and removes only its storage key on double click', async () => {
localStorage.setItem ('sidebar:test-sidebar:right:width', '420')
localStorage.setItem ('sidebar:other-sidebar:right:width', '480')
const { container } = renderSidebar ({ side: 'right' }, 900)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('420px')
})
fireEvent.doubleClick (screen.getByRole ('separator', { name: 'サイドバー幅' }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('256px')
})
expect (localStorage.getItem ('sidebar:test-sidebar:right:width')).toBeNull ()
expect (localStorage.getItem ('sidebar:other-sidebar:right:width')).toBe ('480')
})
it ('clamps current width on maxWidth changes without re-reading storage', async () => {
localStorage.setItem ('sidebar:test-sidebar:left:width', '420')
const { container, rerender } = renderSidebar ({ maxWidth: 420 }, 900)
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('420px')
})
rerender (sidebarTree ({ maxWidth: 300 }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('300px')
})
rerender (sidebarTree ({ maxWidth: 500 }))
await waitFor (() => {
expect (sidebarWidth (sidebarRoot (container))).toBe ('300px')
})
})
})
+6 -338
ファイルの表示
@@ -1,324 +1,16 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation' import type { FC, ReactNode } from 'react'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react' type Props = { children: ReactNode }
import { useCallback, useEffect, useRef, useState } from 'react'
type SidebarSide = 'left' | 'right'
type Props = {
children: ReactNode
className?: string
maxWidth?: number
onWidthChange?: (width: number) => void
sidebarKey: string
side?: SidebarSide }
type ResizeState = {
pointerId: number
startWidth: number
startX: number
side: SidebarSide }
type PendingPressState = ResizeState & { startY: number }
const DEFAULT_SIDEBAR_WIDTH = 256
const MIN_SIDEBAR_WIDTH = 192
const MIN_MAIN_WIDTH = 360
const MAX_VIEWPORT_WIDTH_RATIO = .8
const LONG_PRESS_DELAY_MS = 350
const TOUCH_MOVE_THRESHOLD_PX = 8
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string => const SidebarComponent: FC<Props> = ({ children }) => (
`sidebar:${ sidebarKey }:${ side }:width`
const getSidebarMaxWidth = (containerWidth: number): number => {
const viewportWidth =
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH)
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
}
const readStoredSidebarWidth = (
sidebarKey: string,
side: SidebarSide): number => {
if (typeof window === 'undefined')
return DEFAULT_SIDEBAR_WIDTH
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
const width = value == null ? Number.NaN : Number (value)
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
}
const SidebarComponent: FC<Props> = ({
children,
className,
maxWidth,
onWidthChange,
sidebarKey,
side = 'left',
}) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth)
const getContainerWidth = useCallback (() => {
const containerWidth = rootRef.current
?.closest ('[data-sidebar-container]')
?.clientWidth
?? window.innerWidth
return containerWidth
}, [])
const getClampedWidth = useCallback ((
nextWidth: number,
containerWidth: number): number => {
const viewportWidth =
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const maxAllowedWidth =
maxWidthRef.current == null
? getSidebarMaxWidth (containerWidth)
: Math.max (
MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
const [width, setWidth] = useState (() => (
typeof window === 'undefined'
? DEFAULT_SIDEBAR_WIDTH
: getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
window.innerWidth)))
const [resizing, setResizing] = useState (false)
const widthRef = useRef (width)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const pendingPressRef = useRef<PendingPressState | null> (null)
const resizeRef = useRef<ResizeState | null> (null)
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
const clearLongPressTimer = useCallback (() => {
if (longPressTimerRef.current == null)
return
clearTimeout (longPressTimerRef.current)
longPressTimerRef.current = null
}, [])
const restoreBodyStyle = useCallback (() => {
if (bodyStyleRef.current == null)
return
document.body.style.cursor = bodyStyleRef.current.cursor
document.body.style.userSelect = bodyStyleRef.current.userSelect
bodyStyleRef.current = null
}, [])
const startResize = useCallback ((state: ResizeState) => {
clearLongPressTimer ()
pendingPressRef.current = null
resizeRef.current = state
setResizing (true)
if (bodyStyleRef.current == null)
{
bodyStyleRef.current = {
cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect }
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [clearLongPressTimer])
const finishResize = useCallback (() => {
clearLongPressTimer ()
pendingPressRef.current = null
const state = resizeRef.current
resizeRef.current = null
if (state != null)
window.localStorage.setItem (
storageKeyForSidebar (sidebarKey, state.side),
String (widthRef.current))
setResizing (false)
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
const applyWidth = useCallback ((nextWidth: number) => {
if (nextWidth === widthRef.current)
return
widthRef.current = nextWidth
setWidth (nextWidth)
}, [])
useEffect (() => {
maxWidthRef.current = maxWidth
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth])
const updateWidth = useCallback ((clientX: number) => {
const state = resizeRef.current
if (state == null)
return
const containerWidth = getContainerWidth ()
const delta = state.side === 'left'
? clientX - state.startX
: state.startX - clientX
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
return
if (ev.pointerType === 'mouse' && ev.button !== 0)
return
ev.preventDefault ()
ev.stopPropagation ()
ev.currentTarget.setPointerCapture (ev.pointerId)
const state = {
pointerId: ev.pointerId,
side,
startWidth: widthRef.current,
startX: ev.clientX }
if (ev.pointerType === 'mouse')
{
startResize (state)
return
}
pendingPressRef.current = { ...state, startY: ev.clientY }
clearLongPressTimer ()
longPressTimerRef.current = setTimeout (() => {
if (pendingPressRef.current != null)
startResize (pendingPressRef.current)
}, LONG_PRESS_DELAY_MS)
}
const handlePointerMove = (ev: PointerEvent<HTMLDivElement>) => {
ev.stopPropagation ()
if (resizeRef.current != null)
{
ev.preventDefault ()
updateWidth (ev.clientX)
return
}
const pendingPress = pendingPressRef.current
if (pendingPress == null || pendingPress.pointerId !== ev.pointerId)
return
const deltaX = ev.clientX - pendingPress.startX
const deltaY = ev.clientY - pendingPress.startY
const distance = Math.hypot (deltaX, deltaY)
if (distance >= TOUCH_MOVE_THRESHOLD_PX)
{
clearLongPressTimer ()
pendingPressRef.current = null
}
}
const handlePointerEnd = (ev: PointerEvent<HTMLDivElement>) => {
ev.stopPropagation ()
if (ev.currentTarget.hasPointerCapture (ev.pointerId))
ev.currentTarget.releasePointerCapture (ev.pointerId)
finishResize ()
}
const resetWidth = useCallback (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
applyWidth (nextWidth)
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
ev.preventDefault ()
ev.stopPropagation ()
resetWidth ()
}
useEffect (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
useEffect (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
if (typeof window === 'undefined')
return
const handleResize = () => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}
window.addEventListener ('resize', handleResize)
return () => window.removeEventListener ('resize', handleResize)
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
onWidthChange?.(width)
}, [onWidthChange, width])
useEffect (() => () => {
clearLongPressTimer ()
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle])
const style = {
'--sidebar-width': `${ width }px` } as CSSProperties
return (
<motion.div <motion.div
ref={rootRef} layout="position"
layout={animationMode === 'off' ? false : 'position'} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
transition={{ layout: layoutTransition }} className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar">
style={style}
className={cn (
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
className)}>
<Helmet> <Helmet>
<style> <style>
{` {`
@@ -334,31 +26,7 @@ const SidebarComponent: FC<Props> = ({
</style> </style>
</Helmet> </Helmet>
<div className="h-full overflow-y-auto p-4 sidebar">
{children} {children}
</div>
<div
role="separator"
aria-orientation="vertical"
aria-label="サイドバー幅"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick}
className={cn (
'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
'touch-none select-none',
side === 'left' ? '-right-2' : '-left-2',
'before:absolute before:top-0 before:bottom-0 before:w-px',
'before:bg-neutral-500/0 hover:before:bg-neutral-500/30',
'before:transition-colors before:duration-150',
side === 'left'
? 'before:left-1/2'
: 'before:right-1/2',
resizing && 'before:bg-neutral-500/40')}/>
</motion.div>) </motion.div>)
}
export default SidebarComponent export default SidebarComponent
-198
ファイルの表示
@@ -1,198 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
import type { ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientLinkPreloadMode,
ClientTagRelationDisplayMode } from '@/lib/settings'
type Props = {
sectionClassName: string
onDirtyStateChange?: (
dirty: boolean,
discard: () => void,
) => void }
type SegmentedOption<T extends string> = {
value: T
label: string }
type SegmentedControlProps<T extends string> = {
value: T
options: SegmentedOption<T>[]
onChange: (value: T) => void }
type SettingBlockProps = {
title: string
description?: string
children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
{ value: 'off', label: 'なし' },
{ value: 'reduced', label: '控えめ' },
{ value: 'normal', label: '通常' }]
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
{ value: 'off', label: '読み込まない' },
{ value: 'manual', label: 'クリック時' },
{ value: 'auto', label: '自動' }]
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
{ value: 'off', label: 'しない' },
{ value: 'intent', label: 'する' }]
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
{ value: 'flat', label: 'まとめない' },
{ value: 'grouped', 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-primary text-primary-foreground shadow-sm ring-1 ring-primary/40'
: 'bg-transparent 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 px-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,
onDirtyStateChange },
) => {
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)
}
useEffect (() => {
onDirtyStateChange?.(hasUnsavedChanges, handleDiscard)
return () => {
onDirtyStateChange?.(false, () => { })
}
}, [handleDiscard, hasUnsavedChanges, onDirtyStateChange])
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>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="destructive"
disabled={hasUnsavedChanges === false}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={hasUnsavedChanges === false}
onClick={handleSave}>
</Button>
</div>
</div>
<div className="space-y-4">
<SettingBlock title="アニメーション">
<SegmentedControl
value={draftSettings.animation ?? 'normal'}
options={animationOptions}
onChange={value => updateDraft ('animation', value)}/>
</SettingBlock>
<SettingBlock
title="リンク先の先読み"
description="マウスを置いた時点でリンク先のデータを取得します。">
<SegmentedControl
value={draftSettings.linkPreload ?? 'intent'}
options={linkPreloadOptions}
onChange={value => updateDraft ('linkPreload', value)}/>
</SettingBlock>
<SettingBlock title="タグの親子関係表示">
<SegmentedControl
value={draftSettings.tagRelationDisplay ?? 'grouped'}
options={tagRelationDisplayOptions}
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
</SettingBlock>
<SettingBlock title="埋め込み自動読込">
<SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions}
onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock>
</div>
</section>)
}
export default BehaviourSettingsSection
-262
ファイルの表示
@@ -1,262 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import {
getConflictingShortcutActionIds,
formatKeyBinding,
isSpaceBinding,
keyBindingFromKeyboardEvent,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import { getEffectiveKeyBindings } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { FC } from 'react'
type Props = {
sectionClassName: string
onDirtyStateChange?: (
dirty: boolean,
discard: () => void,
) => void
}
const KeyboardSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
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)
}
useEffect (() => {
onDirtyStateChange?.(hasUnsavedChanges, handleDiscard)
return () => {
onDirtyStateChange?.(false, () => { })
}
}, [handleDiscard, hasUnsavedChanges, onDirtyStateChange])
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>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setDraftKeyboardSettings (current => ({
...current,
bindings: { },
}))
}}>
</Button>
<Button
type="button"
variant="destructive"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
onClick={handleSave}>
</Button>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={draftKeyboardSettings.enabled !== false}
onChange={event => {
setDraftKeyboardSettings (current => ({
...current,
enabled: event.target.checked,
}))
}}/>
<span></span>
</label>
{capturingActionId != null && (
<p className="text-sm text-muted-foreground">
Escape Backspace / Delete
</p>)}
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2 font-mono">
{formatKeyBinding (row.currentBinding)}
</td>
<td className="p-2">
<Button
type="button"
variant={capturingActionId === row.id ? 'default' : 'outline'}
className="h-8"
onClick={() => {
setCapturingActionId (
capturingActionId === row.id ? null : row.id,
)
}}>
{capturingActionId === row.id ? '入力待ち…' : '変更'}
</Button>
</td>
<td className="p-2">
<Button
type="button"
variant="ghost"
className="h-8"
disabled={!(row.hasCustomBinding)}
onClick={() => {
setDraftKeyboardSettings (current => {
const nextBindings = { ...(current.bindings ?? { }) }
delete nextBindings[row.id]
return {
...current,
bindings: nextBindings,
}
})
}}>
</Button>
</td>
<td className="p-2">
{row.hasConflict
? <span className="text-red-600 dark:text-red-400"></span>
: 'なし'}
</td>
</tr>))}
</tbody>
</table>
</div>
</section>)
}
export default KeyboardSettingsSection
-87
ファイルの表示
@@ -1,87 +0,0 @@
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
+22 -150
ファイルの表示
@@ -10,10 +10,6 @@
{ {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 222.2 84% 4.9%; --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: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%; --primary-foreground: 210 40% 98%;
@@ -33,40 +29,12 @@
--border: 214.3 31.8% 91.4%; --border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%; --ring: 222.2 84% 4.9%;
--theme-link: 221.2 83.2% 53.3%;
--top-nav-root-bg-mobile: #fef08a;
--top-nav-root-bg-desktop: #fefce8;
--top-nav-active-bg: #fef08a;
--top-nav-submenu-bg: #fef08a;
--top-nav-mobile-menu-bg: #fef08a;
--top-nav-mobile-active-bg: #fefce8;
--top-nav-brand-link: #db2777;
--top-nav-menu-link: #1d4ed8;
--tag-colour-deerjikist: #9f1239;
--tag-colour-deerjikist-hover: #b62a51;
--tag-colour-meme: #6b21a8;
--tag-colour-meme-hover: #8238bf;
--tag-colour-character: #4d7c0f;
--tag-colour-character-hover: #649326;
--tag-colour-general: #155e75;
--tag-colour-general-hover: #2c758c;
--tag-colour-material: #c2410c;
--tag-colour-material-hover: #d95823;
--tag-colour-meta: #a16207;
--tag-colour-meta-hover: #b8791e;
--tag-colour-nico: #4b5563;
--tag-colour-nico-hover: #626c7a;
} }
.dark .dark
{ {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --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: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 222.2 47.4% 11.2%;
@@ -86,28 +54,20 @@
--border: 217.2 32.6% 17.5%; --border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%; --ring: 212.7 26.8% 83.9%;
--top-nav-root-bg-mobile: #230505;
--top-nav-root-bg-desktop: #230505;
--top-nav-active-bg: #450a0a;
--top-nav-submenu-bg: #450a0a;
--top-nav-mobile-menu-bg: #230505;
--top-nav-mobile-active-bg: #450a0a;
--top-nav-brand-link: #f9a8d4;
--top-nav-menu-link: #93c5fd;
} }
body body
{ {
@apply overflow-x-clip bg-background text-foreground; @apply overflow-x-clip;
} }
a a
{ {
color: hsl(var(--theme-link)); @apply text-blue-700 dark:text-blue-300;
} }
a:hover a:hover
{ {
color: color-mix(in oklab, hsl(var(--theme-link)), white 18%); @apply text-blue-500 dark:text-blue-100;
} }
} }
@@ -118,8 +78,8 @@
font-weight: 400; font-weight: 400;
color-scheme: light dark; color-scheme: light dark;
color: hsl(var(--foreground)); color: rgba(255, 255, 255, 0.87);
background-color: hsl(var(--background)); background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@@ -127,23 +87,6 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
:root[data-animation='reduced'] *,
:root[data-animation='off'] *
{
scroll-behavior: auto !important;
}
:root[data-animation='off'] *,
:root[data-animation='off'] *::before,
:root[data-animation='off'] *::after
{
animation-duration: 0ms !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0ms !important;
transition-delay: 0ms !important;
}
a a
{ {
font-weight: 500; font-weight: 500;
@@ -154,15 +97,28 @@ body
{ {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
background: hsl(var(--background));
color: hsl(var(--foreground));
} }
#root #root
{ {
min-height: 100dvh; min-height: 100dvh;
background: hsl(var(--background)); }
color: hsl(var(--foreground));
@media (prefers-color-scheme: light)
{
:root
{
color: #213547;
background-color: #ffffff;
}
a:hover
{
color: #747bff;
}
button
{
background-color: #f9f9f9;
}
} }
@keyframes wiki-blink @keyframes wiki-blink
@@ -176,87 +132,3 @@ body
0%, 100% { color: #f87171; } 0%, 100% { color: #f87171; }
50% { color: #60a5fa; } 50% { color: #60a5fa; }
} }
.tag-marquee
{
position: relative;
}
.tag-link-colour
{
color: var(--tag-link-colour);
}
.tag-link-hover-colour:hover
{
color: var(--tag-link-hover-colour) !important;
}
.top-nav-root
{
background: var(--top-nav-root-bg-mobile);
}
@media (min-width: 768px)
{
.top-nav-root
{
background: var(--top-nav-root-bg-desktop);
}
}
.top-nav-brand-link,
.top-nav-mobile-toggle
{
color: var(--top-nav-brand-link);
}
.top-nav-brand-link:hover,
.top-nav-mobile-toggle:hover
{
color: color-mix(in oklab, var(--top-nav-brand-link), white 18%);
}
.top-nav-menu-link,
.top-nav-mobile-row
{
color: var(--top-nav-menu-link);
}
.top-nav-menu-link:hover,
.top-nav-mobile-row:hover
{
color: color-mix(in oklab, var(--top-nav-menu-link), white 18%);
}
.top-nav-highlight
{
background: var(--top-nav-active-bg);
}
.top-nav-submenu
{
background: var(--top-nav-submenu-bg);
}
.top-nav-mobile-menu
{
background: var(--top-nav-mobile-menu-bg);
}
.top-nav-mobile-active
{
background: var(--top-nav-mobile-active-bg);
}
.tag-marquee__animated
{
position: absolute;
inset: 0 auto 0 0;
min-width: 100%;
width: max-content;
opacity: 0;
pointer-events: none;
white-space: nowrap;
will-change: transform;
}
-34
ファイルの表示
@@ -1,34 +0,0 @@
import type { Transition } from 'framer-motion'
import type { ClientAnimationMode } from '@/lib/settings'
type ClientAnimationTransitionOptions = {
normal: Transition
reduced?: Transition
off?: Transition }
export const clientAnimationsOff = (
animationMode: ClientAnimationMode,
): boolean =>
animationMode === 'off'
export const clientAnimationTransition = (
animationMode: ClientAnimationMode,
{ normal, reduced, off }: ClientAnimationTransitionOptions,
): Transition => {
if (animationMode === 'off')
return off ?? { duration: 0 }
if (animationMode === 'reduced')
return reduced ?? { duration: .08, ease: 'linear' as const }
return normal
}
export const clientScrollBehaviour = (
animationMode: ClientAnimationMode,
): ScrollBehavior =>
animationMode === 'off' ? 'auto' : 'smooth'
-65
ファイルの表示
@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_KEY_BINDINGS,
formatKeyBinding,
getConflictingShortcutActionIds,
isEditableEventTarget,
keyBindingFromKeyboardEvent,
resolveShortcutScope,
SHORTCUT_DEFINITIONS_BY_ID } from '@/lib/keyboardShortcuts'
describe ('keyboardShortcuts', () => {
it ('defines the default key bindings used by the settings screen', () => {
expect (SHORTCUT_DEFINITIONS_BY_ID['global.openShortcutHelp']).toMatchObject ({
label: 'ショートカット一覧を開く',
scope: 'global',
defaultBinding: { key: '?', shift: true } })
expect (DEFAULT_KEY_BINDINGS['global.focusSearch']).toEqual ({ key: '/' })
expect (DEFAULT_KEY_BINDINGS['navigation.posts']).toEqual ({ key: 'p' })
expect (DEFAULT_KEY_BINDINGS['pagination.next']).toEqual ({ key: 'ArrowRight' })
expect (DEFAULT_KEY_BINDINGS['settings.theme']).toEqual ({ key: '3' })
})
it ('formats key bindings for display', () => {
expect (formatKeyBinding ({ key: '?', shift: true })).toBe ('Shift + ?')
expect (formatKeyBinding ({ key: '/' })).toBe ('/')
expect (formatKeyBinding ({ key: 'p' })).toBe ('P')
expect (formatKeyBinding ({ key: 'ArrowRight' })).toBe ('ArrowRight')
expect (formatKeyBinding (null)).toBe ('未割当')
})
it ('normalises keyboard events without assigning space by default', () => {
const event = { key: 'P',
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false } as KeyboardEvent
expect (keyBindingFromKeyboardEvent (event)).toEqual ({ key: 'p' })
expect (DEFAULT_KEY_BINDINGS['pagination.next']).not.toEqual ({ key: ' ' })
})
it ('detects conflicting action bindings', () => {
const conflicts = getConflictingShortcutActionIds ({
...DEFAULT_KEY_BINDINGS,
'navigation.posts': { key: 'p' },
'navigation.tags': { key: 'p' } })
expect (conflicts.has ('navigation.posts')).toBe (true)
expect (conflicts.has ('navigation.tags')).toBe (true)
expect (conflicts.has ('navigation.wiki')).toBe (false)
})
it ('resolves route scopes and editable event targets', () => {
const input = document.createElement ('input')
const div = document.createElement ('div')
div.setAttribute ('role', 'textbox')
expect (resolveShortcutScope ('/posts/search')).toBe ('postSearch')
expect (resolveShortcutScope ('/users/settings')).toBe ('settings')
expect (resolveShortcutScope ('/materials/1')).toBe ('materials')
expect (isEditableEventTarget (input)).toBe (true)
expect (isEditableEventTarget (div)).toBe (true)
expect (isEditableEventTarget (document.createElement ('button'))).toBe (false)
})
})
-328
ファイルの表示
@@ -1,328 +0,0 @@
export type ShortcutScope =
| 'global'
| 'posts'
| 'postSearch'
| 'tags'
| 'settings'
| 'wiki'
| 'materials'
export type ShortcutActionId =
| 'global.openShortcutHelp'
| 'global.focusSearch'
| 'navigation.posts'
| 'navigation.tags'
| 'navigation.materials'
| 'navigation.wiki'
| 'pagination.next'
| 'pagination.previous'
| 'settings.account'
| 'settings.behavior'
| 'settings.theme'
| 'settings.keyboard'
export type KeyBinding = {
key: string
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}
export type ShortcutDefinition = {
id: ShortcutActionId
label: string
scope: ShortcutScope
defaultBinding: KeyBinding | null
}
export const SHORTCUT_SCOPE_LABELS: Record<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.behavior',
label: '設定: 動作',
scope: 'settings',
defaultBinding: { key: '2' },
},
{
id: 'settings.theme',
label: '設定: テーマ',
scope: 'settings',
defaultBinding: { key: '3' },
},
{
id: 'settings.keyboard',
label: '設定: キーボード',
scope: 'settings',
defaultBinding: { key: '4' },
},
]
export const SHORTCUT_DEFINITIONS_BY_ID =
SHORTCUT_DEFINITIONS.reduce<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.behavior': SHORTCUT_DEFINITIONS[9],
'settings.theme': SHORTCUT_DEFINITIONS[10],
'settings.keyboard': 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.behavior': null,
'settings.theme': null,
'settings.keyboard': null,
},
)
const normaliseKey = (key: string): string => {
if (key === 'Spacebar')
return ' '
if (key.length === 1 && key !== ' ')
return key.toLowerCase ()
return key
}
const isModifierKey = (key: string): boolean =>
key === 'Shift'
|| key === 'Control'
|| key === 'Alt'
|| key === 'Meta'
export const normaliseKeyBinding = (value: unknown): KeyBinding | null => {
if (!(value) || typeof value !== 'object')
return null
const binding = value as Partial<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 === ' '
-2
ファイルの表示
@@ -70,7 +70,6 @@ describe ('posts API functions', () => {
id: 5, id: 5,
title: 'new title', title: 'new title',
tags: 'tag', tags: 'tag',
duration: null,
parentPostIds: '1 2', parentPostIds: '1 2',
originalCreatedFrom: null, originalCreatedFrom: null,
originalCreatedBefore: '2026-01-02T00:00:00Z', originalCreatedBefore: '2026-01-02T00:00:00Z',
@@ -83,7 +82,6 @@ describe ('posts API functions', () => {
{ {
title: 'new title', title: 'new title',
tags: 'tag', tags: 'tag',
duration: null,
parent_post_ids: '1 2', parent_post_ids: '1 2',
original_created_from: null, original_created_from: null,
original_created_before: '2026-01-02T00:00:00Z', original_created_before: '2026-01-02T00:00:00Z',
-2
ファイルの表示
@@ -44,7 +44,6 @@ export const updatePost = async (
post: { id: number post: { id: number
title: string | null title: string | null
tags: string tags: string
duration: string | null
parentPostIds: string parentPostIds: string
originalCreatedFrom: string | null originalCreatedFrom: string | null
originalCreatedBefore: string | null }, originalCreatedBefore: string | null },
@@ -56,7 +55,6 @@ export const updatePost = async (
`/posts/${ post.id }`, `/posts/${ post.id }`,
{ title: post.title, { title: post.title,
tags: post.tags, tags: post.tags,
duration: post.duration,
parent_post_ids: post.parentPostIds, parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom, original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore }, original_created_before: post.originalCreatedBefore },
-108
ファイルの表示
@@ -1,108 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { applyClientAnimationMode,
applyThemeTokens,
buildThemeTokens,
CLIENT_SETTINGS_STORAGE_KEY,
DARK_THEME_TOKENS,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
getClientThemeMode,
LIGHT_THEME_TOKENS,
loadClientSettings,
setClientListSettings,
setClientThemeAppearance } from '@/lib/settings'
import type { FetchPostsOrder } from '@/types'
const installMatchMedia = (matches: boolean) => {
Object.defineProperty (window, 'matchMedia', {
configurable: true,
value: vi.fn ().mockImplementation (() => ({
matches,
addEventListener: vi.fn (),
removeEventListener: vi.fn () })) })
}
describe ('settings', () => {
beforeEach (() => {
localStorage.clear ()
document.documentElement.removeAttribute ('data-animation')
document.documentElement.removeAttribute ('style')
document.documentElement.className = ''
installMatchMedia (false)
})
it ('stores list settings under the shared client settings key', () => {
setClientListSettings ('postSearch', {
limit: 50,
order: 'title:asc' })
expect (getClientListLimit ('postSearch')).toBe (50)
expect (getClientListOrder<FetchPostsOrder> ('postSearch')).toBe ('title:asc')
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'title:asc' } } })
})
it ('ignores invalid list limits and keeps stored order strings', () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postList: { limit: 30, order: DEFAULT_POST_LIST_ORDER } } }))
expect (getClientListLimit ('postList')).toBeNull ()
expect (getClientListOrder<FetchPostsOrder> ('postList')).toBe (
DEFAULT_POST_LIST_ORDER)
})
it ('stores theme mode and light/dark slot selections in client settings', () => {
setClientThemeAppearance ({
themeMode: 'dark',
activeLightThemeSlotNo: 2,
activeDarkThemeSlotNo: 3 })
expect (getClientThemeMode ()).toBe ('dark')
expect (loadClientSettings ().appearance).toMatchObject ({
themeMode: 'dark',
activeLightThemeSlotNo: 2,
activeDarkThemeSlotNo: 3 })
})
it ('applies animation mode to the document dataset', () => {
applyClientAnimationMode ('off')
expect (document.documentElement.dataset.animation).toBe ('off')
applyClientAnimationMode ('reduced')
expect (document.documentElement.dataset.animation).toBe ('reduced')
applyClientAnimationMode ('normal')
expect (document.documentElement.dataset.animation).toBeUndefined ()
})
it ('normalises TopNav menu link tokens to complete CSS colours', () => {
const tokens = buildThemeTokens ('light', {
link: '221.2 83.2% 53.3%',
topNavMenuLink: '221.2 83.2% 53.3%' })
expect (tokens.link).toBe ('221.2 83.2% 53.3%')
expect (tokens.topNavMenuLink).toBe ('hsl(221.2 83.2% 53.3%)')
})
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe (
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
DARK_THEME_TOKENS.topNavActiveBackground)
})
it ('sets shadcn tokens and TopNav tokens with their different value formats', () => {
const tokens = buildThemeTokens ('dark', { link: '213.1 93.9% 67.8%' })
applyThemeTokens (tokens)
expect (document.documentElement.style.getPropertyValue ('--theme-link')).toBe (
'213.1 93.9% 67.8%')
expect (document.documentElement.style.getPropertyValue ('--top-nav-menu-link')).toBe (
'hsl(213.1 93.9% 67.8%)')
})
})
ファイル差分が大きすぎるため省略します 差分を読込み
-26
ファイルの表示
@@ -1,26 +0,0 @@
import { useEffect, useState } from 'react'
import {
CLIENT_SETTINGS_EVENT,
getClientBehaviorSettings,
} from '@/lib/settings'
import type { ClientBehaviorSettings } from '@/lib/settings'
export const useClientBehaviourSettings = (): ClientBehaviorSettings => {
const [settings, setSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useEffect (() => {
const handleClientSettingsChange = () => {
setSettings (getClientBehaviorSettings ())
}
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
return () =>
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
}, [])
return settings
}
-322
ファイルの表示
@@ -1,322 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
import {
DEFAULT_KEY_BINDINGS,
getConflictingShortcutActionIds,
isEditableEventTarget,
keyBindingFromKeyboardEvent,
resolveShortcutScope,
serialiseKeyBinding,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import {
getClientKeyboardSettings,
getEffectiveKeyBindings,
setClientKeyboardSettings,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type {
KeyBinding,
ShortcutActionId,
ShortcutScope,
} from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { PropsWithChildren } from 'react'
type ShortcutHandler = () => void
type ShortcutHandlers = Partial<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 { confirmDiscardNavigation } = useUnsavedChangesGuard ()
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 guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
'global.openShortcutHelp': () => {
setShortcutHelpOpen (true)
},
'global.focusSearch': () => {
focusSearchTarget ()
},
'navigation.posts': () => guardedNavigate ('/posts'),
'navigation.tags': () => guardedNavigate ('/tags'),
'navigation.materials': () => guardedNavigate ('/materials'),
'navigation.wiki': () => guardedNavigate ('/wiki'),
'settings.account': () => guardedNavigate ('/users/settings?tab=account'),
'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'),
'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'),
'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'),
}),
[guardedNavigate],
)
const availableActionIds = useMemo<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 = mergedRegisteredHandlers[actionId] ?? builtinHandlers[actionId]
if (handler == null)
return
event.preventDefault ()
handler ()
}
document.addEventListener ('keydown', onKeyDown)
return () => document.removeEventListener ('keydown', onKeyDown)
}, [
activeScope,
conflictActionIds,
effectiveBindings,
builtinHandlers,
keyboardSettings.enabled,
mergedRegisteredHandlers,
shortcutHelpOpen,
])
const value = useMemo<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 ()
const handlersRef = useRef (handlers)
const actionIdsKey = Object.keys (handlers).sort ().join ('|')
useEffect (() => {
handlersRef.current = handlers
}, [handlers])
const stableHandlers = useMemo<ShortcutHandlers> (() => {
return actionIdsKey.split ('|').filter (actionId => actionId !== '')
.reduce<ShortcutHandlers> ((nextHandlers, actionId) => {
const key = actionId as ShortcutActionId
nextHandlers[key] = () => {
handlersRef.current[key]?.()
}
return nextHandlers
}, { })
}, [actionIdsKey])
useEffect (
() => registerHandlers (stableHandlers),
[registerHandlers, stableHandlers],
)
}
export {
DEFAULT_KEY_BINDINGS,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
}
-69
ファイルの表示
@@ -1,69 +0,0 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, PropsWithChildren } from 'react'
type UnsavedChangesSource = {
dirty: boolean
discard: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean> }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
}, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
if (!(source?.dirty))
return true
const confirmed = await dialogue.confirm ({
title: '未保存の変更があります',
description: 'このまま移動すると、保存していない変更は失われます。',
cancelText: 'このページに残る',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
await source.discard ()
return true
}, [dialogue, source])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true,
registerUnsavedChangesSource,
confirmDiscardNavigation,
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
</UnsavedChangesGuardContext.Provider>)
}
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
const context = useContext (UnsavedChangesGuardContext)
if (context == null)
throw new Error ('UnsavedChangesGuardProvider が必要です.')
return context
}
+1 -12
ファイルの表示
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { cn, msToTime, originalCreatedAtString, toDate } from '@/lib/utils' import { cn, originalCreatedAtString, toDate } from '@/lib/utils'
describe ('utils', () => { describe ('utils', () => {
it ('converts strings to dates and leaves date instances intact', () => { it ('converts strings to dates and leaves date instances intact', () => {
@@ -26,14 +26,3 @@ describe ('utils', () => {
).toContain ('時刻不詳') ).toContain ('時刻不詳')
}) })
}) })
describe ('msToTime', () => {
it ('keeps milliseconds when present', () => {
expect (msToTime (60_500)).toBe ('1:00.500')
expect (msToTime (60_001)).toBe ('1:00.001')
})
it ('omits milliseconds when they are zero', () => {
expect (msToTime (60_000)).toBe ('1:00')
})
})
-16
ファイルの表示
@@ -71,22 +71,6 @@ export const originalCreatedAtString = (
} }
export const msToTime = (ms: number): string => {
const totalS = Math.trunc (ms / 1_000)
const s = String (totalS % 60)
const min = String (Math.trunc (totalS / 60) % 60)
const h = Math.trunc (totalS / 3_600)
const remainderMs = ms % 1_000
const base =
(h > 0
? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }`
: `${ min }:${ s.padStart (2, '0') }`)
return remainderMs > 0 ? `${ base }.${ remainderMs.toString ().padStart (3, '0') }` : base
}
export const inputClass = (invalid?: boolean, className?: string): string => export const inputClass = (invalid?: boolean, className?: string): string =>
cn ('w-full rounded border p-2', cn ('w-full rounded border p-2',
(invalid (invalid
-3
ファイルの表示
@@ -4,7 +4,6 @@ import { HelmetProvider } from 'react-helmet-async'
import '@/index.css' import '@/index.css'
import App from '@/App' import App from '@/App'
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
const helmetContext = { } const helmetContext = { }
@@ -14,8 +13,6 @@ const client = new QueryClient ({
gcTime: 30 * 60 * 1000, gcTime: 30 * 60 * 1000,
retry: 1 } } }) retry: 1 } } })
applyClientAnimationMode (getClientAnimationMode ())
createRoot (document.getElementById ('root')!).render ( createRoot (document.getElementById ('root')!).render (
<HelmetProvider context={helmetContext}> <HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
+10 -14
ファイルの表示
@@ -24,11 +24,6 @@ import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
import { isQuestionHardFilteredAfterAnswers, import { isQuestionHardFilteredAfterAnswers,
monthForCondition } from '@/lib/gekanatorQuestionFilters' monthForCondition } from '@/lib/gekanatorQuestionFilters'
import { gekanatorKeys } from '@/lib/queryKeys' import { gekanatorKeys } from '@/lib/queryKeys'
import {
getClientGekanatorBackgroundMotion,
setClientGekanatorBackgroundMotion,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -190,6 +185,7 @@ const softenedAnswerWeight = .35
const confidenceTemperature = 6 const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1' const gameStorageKey = 'gekanator:game:v1'
const recentGamesStorageKey = 'gekanator:recent-games:v1' const recentGamesStorageKey = 'gekanator:recent-games:v1'
const backgroundMotionStorageKey = 'gekanator:background-motion:v1'
const maxStoredRecentGames = 12 const maxStoredRecentGames = 12
const specialOriginalMonthDayLabels: Record<string, string> = { const specialOriginalMonthDayLabels: Record<string, string> = {
'1-1': '元日', '1-1': '元日',
@@ -433,13 +429,18 @@ const storeRecentGameSummary = (
const loadBackgroundMotionMode = (): BackgroundMotionMode => { const loadBackgroundMotionMode = (): BackgroundMotionMode => {
const fallbackMode = 'on'
try try
{ {
return getClientGekanatorBackgroundMotion () const raw = localStorage.getItem (backgroundMotionStorageKey)
if (raw === 'off' || raw === 'calm' || raw === 'on')
return raw
return fallbackMode
} }
catch catch
{ {
return 'on' return fallbackMode
} }
} }
@@ -3678,8 +3679,6 @@ const expectedAnswerFor = (
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const storedGame = useMemo (loadStoredGame, []) const storedGame = useMemo (loadStoredGame, [])
const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase) const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
const queryClient = useQueryClient () const queryClient = useQueryClient ()
@@ -3917,7 +3916,7 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
useEffect (() => { useEffect (() => {
try try
{ {
setClientGekanatorBackgroundMotion (backgroundMotionMode) localStorage.setItem (backgroundMotionStorageKey, backgroundMotionMode)
} }
catch catch
{ {
@@ -4072,12 +4071,9 @@ const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
return reviewGuessedPostId === reviewCorrectPostId return reviewGuessedPostId === reviewCorrectPostId
}) () }) ()
const effectiveBackgroundMotionMode = (() => { const effectiveBackgroundMotionMode = (() => {
if (animationMode === 'off')
return 'off'
if (backgroundMotionMode === 'off') if (backgroundMotionMode === 'off')
return 'off' return 'off'
if (animationMode === 'reduced' || prefersReducedMotion) if (prefersReducedMotion)
return 'calm' return 'calm'
return backgroundMotionMode return backgroundMotionMode
+1 -3
ファイルの表示
@@ -6,9 +6,7 @@ import type { FC } from 'react'
const MaterialBasePage: FC = () => ( const MaterialBasePage: FC = () => (
<div <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<MaterialSidebar/> <MaterialSidebar/>
<Outlet/> <Outlet/>
</div>) </div>)
+2 -7
ファイルの表示
@@ -9,18 +9,14 @@ import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchMaterialChanges } from '@/lib/materials' import { fetchMaterialChanges } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
const MaterialHistoryPage: FC = () => { const MaterialHistoryPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
@@ -59,9 +55,8 @@ const MaterialHistoryPage: FC = () => {
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => { useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
behavior: clientScrollBehaviour (animationMode) }) }, [location.search])
}, [animationMode, location.search])
const handleSearch = (event: FormEvent) => { const handleSearch = (event: FormEvent) => {
event.preventDefault () event.preventDefault ()
+10 -21
ファイルの表示
@@ -15,7 +15,6 @@ import { SITE_TITLE } from '@/config'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import { fetchPost, toggleViewedFlg } from '@/lib/posts' import { fetchPost, toggleViewedFlg } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
@@ -30,17 +29,7 @@ type Props = { user: User | null }
const PostDetailPage: FC<Props> = ({ user }) => { const PostDetailPage: FC<Props> = ({ user }) => {
const editable = canEditContent (user) const editable = canEditContent (user)
const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
const animationMode = behaviourSettings.animation ?? 'normal'
const { id } = useParams () const { id } = useParams ()
const heroLayoutId = animationMode === 'off' ? undefined : `page-${ id }`
const heroTransition =
animationMode === 'off'
? { duration: 0 }
: animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const postId = String (id ?? '') const postId = String (id ?? '')
const postKey = postsKeys.show (postId) const postKey = postsKeys.show (postId)
@@ -103,16 +92,16 @@ const PostDetailPage: FC<Props> = ({ user }) => {
: 'bg-gray-500 hover:bg-gray-600') : 'bg-gray-500 hover:bg-gray-600')
return ( return (
<div <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<Helmet> <Helmet>
{(post?.thumbnail || post?.thumbnailBase) && ( {(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)} <meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)}
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
</Helmet> </Helmet>
{post && <TagDetailSidebar post={post} className="hidden md:block"/>} <div className="hidden md:block">
{post && <TagDetailSidebar post={post}/>}
</div>
<MainArea className="relative"> <MainArea className="relative">
{post {post
@@ -145,19 +134,17 @@ const PostDetailPage: FC<Props> = ({ user }) => {
</div>) </div>)
})} })}
{thumbnailMode !== 'off' && (post.thumbnail || post.thumbnailBase) && ( {(post.thumbnail || post.thumbnailBase) && (
<motion.div <motion.div
layoutId={heroLayoutId} layoutId={`page-${ id }`}
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px] className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
overflow-hidden rounded-xl pointer-events-none z-50" overflow-hidden rounded-xl pointer-events-none z-50"
initial={{ opacity: 1 }} initial={{ opacity: 1 }}
animate={{ opacity: 0 }} animate={{ opacity: 0 }}
transition={heroTransition}> transition={{ duration: .2, ease: 'easeOut' }}>
<img src={post.thumbnail || post.thumbnailBase || undefined} <img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url} alt={post.title || post.url}
title={post.title || post.url || undefined} title={post.title || post.url || undefined}
loading={thumbnailMode === 'light' ? 'lazy' : 'eager'}
decoding="async"
className="object-cover w-full h-full"/> className="object-cover w-full h-full"/>
</motion.div>)} </motion.div>)}
@@ -192,7 +179,9 @@ const PostDetailPage: FC<Props> = ({ user }) => {
: 'Loading...'} : 'Loading...'}
</MainArea> </MainArea>
{post && <TagDetailSidebar post={post} sp className="md:hidden"/>} <div className="md:hidden">
{post && <TagDetailSidebar post={post} sp/>}
</div>
</div>) </div>)
} }
+5 -19
ファイルの表示
@@ -12,12 +12,9 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPostChanges, updatePost } from '@/lib/posts' import { fetchPostChanges, updatePost } from '@/lib/posts'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags' import { fetchTag } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, originalCreatedAtString } from '@/lib/utils' import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
@@ -40,12 +37,6 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => (
const PostHistoryPage: FC = () => { const PostHistoryPage: FC = () => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
@@ -100,13 +91,9 @@ const PostHistoryPage: FC = () => {
.filter (p => p.type !== 'removed') .filter (p => p.type !== 'removed')
.map (p => p.id) .map (p => p.id)
.join (' ') .join (' ')
const duration =
change.videoMs.current == null
? null
: String (change.videoMs.current / 1_000)
const originalCreatedFrom = change.originalCreatedFrom.current const originalCreatedFrom = change.originalCreatedFrom.current
const originalCreatedBefore = change.originalCreatedBefore.current const originalCreatedBefore = change.originalCreatedBefore.current
await updatePost ({ id, title, tags, duration, parentPostIds, await updatePost ({ id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore }, originalCreatedFrom, originalCreatedBefore },
{ force: true }) { force: true })
@@ -122,9 +109,8 @@ const PostHistoryPage: FC = () => {
} }
useEffect (() => { useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
behavior: clientScrollBehaviour (animationMode) }) }, [location.search])
}, [animationMode, location.search])
const layoutIds: string[] = [] const layoutIds: string[] = []
@@ -208,8 +194,8 @@ const PostHistoryPage: FC = () => {
rowSpan={rowsCnt}> rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.postId }`}> <PrefetchLink to={`/posts/${ change.postId }`}>
<motion.div <motion.div
layoutId={animationMode === 'off' ? undefined : layoutId} layoutId={layoutId}
transition={{ layout: layoutTransition }}> transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
<img src={change.thumbnail.current <img src={change.thumbnail.current
|| change.thumbnailBase.current || change.thumbnailBase.current
|| undefined} || undefined}
+1 -40
ファイルの表示
@@ -1,10 +1,9 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import PostListPage from '@/pages/posts/PostListPage' import PostListPage from '@/pages/posts/PostListPage'
import { buildPost, buildTag, buildWikiPage } from '@/test/factories' import { buildPost, buildTag, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const postsApi = vi.hoisted (() => ({ const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (), fetchPosts: vi.fn (),
@@ -19,11 +18,6 @@ vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/wiki', () => wikiApi) vi.mock ('@/lib/wiki', () => wikiApi)
describe ('PostListPage', () => { describe ('PostListPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('loads posts from the current query and renders the plaza tab', async () => { it ('loads posts from the current query and renders the plaza tab', async () => {
const tag = buildTag ({ name: '虹夏' }) const tag = buildTag ({ name: '虹夏' })
postsApi.fetchPosts.mockResolvedValueOnce ({ postsApi.fetchPosts.mockResolvedValueOnce ({
@@ -42,7 +36,6 @@ describe ('PostListPage', () => {
match: 'all', match: 'all',
page: 2, page: 2,
limit: 20, limit: 20,
order: 'original_created_at:desc',
}), }),
) )
}) })
@@ -78,36 +71,4 @@ describe ('PostListPage', () => {
'/wiki/%E8%99%B9%E5%A4%8F', '/wiki/%E8%99%B9%E5%A4%8F',
) )
}) })
it ('uses stored list limit but keeps the fixed plaza order', async () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postList: { limit: 50, order: 'title:asc' } } }))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (<PostListPage/>, { route: '/posts' })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({
limit: 50,
order: 'original_created_at:desc',
}),
)
})
})
it ('updates the limit from the tab right addon and resets to page 1', async () => {
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
renderWithProviders (<PostListPage/>, { route: '/posts?page=3' })
fireEvent.change (await screen.findByLabelText ('件数'), { target: { value: '100' } })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
}) })
+5 -75
ファイルの表示
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import PostList from '@/components/PostList' import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
@@ -13,24 +13,12 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' 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 { fetchWikiPageByTitle } from '@/lib/wiki'
import type { FC } from 'react' import type { FC } from 'react'
import type { WikiPage } from '@/types' 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 PostListPage: FC = () => {
const containerRef = useRef<HTMLDivElement | null> (null) const containerRef = useRef<HTMLDivElement | null> (null)
@@ -38,7 +26,6 @@ const PostListPage: FC = () => {
const [wikiPage, setWikiPage] = useState<WikiPage | null> (null) const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
const location = useLocation () const location = useLocation ()
const navigate = useNavigate ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const tagsQuery = query.get ('tags') ?? '' const tagsQuery = query.get ('tags') ?? ''
const anyFlg = query.get ('match') === 'any' const anyFlg = query.get ('match') === 'any'
@@ -46,18 +33,13 @@ const PostListPage: FC = () => {
const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery]) const tags = useMemo (() => tagsQuery.split (' ').filter (e => e !== ''), [tagsQuery])
const tagsKey = tags.join (' ') const tagsKey = tags.join (' ')
const page = Number (query.get ('page') ?? 1) const page = Number (query.get ('page') ?? 1)
const limit = ( const limit = Number (query.get ('limit') ?? 20)
parseLimit (query.get ('limit'))
?? getClientListLimit ('postList')
?? DEFAULT_POST_LIST_LIMIT
)
const order = 'original_created_at:desc' as const
const keys = { const keys = {
tags: tagsKey, match, page, limit, tags: tagsKey, match, page, limit,
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '', url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '', createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order } as const order: 'original_created_at:desc' } as const
const { data, isLoading: loading } = useQuery ({ const { data, isLoading: loading } = useQuery ({
queryKey: postsKeys.index (keys), queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) }) queryFn: () => fetchPosts (keys) })
@@ -65,40 +47,6 @@ const PostListPage: FC = () => {
const cursor = '' const cursor = ''
const totalPages = data ? Math.ceil (data.count / limit) : 0 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 (() => { useLayoutEffect (() => {
scroll (0, 0) scroll (0, 0)
@@ -122,7 +70,6 @@ const PostListPage: FC = () => {
return ( return (
<div <div
data-sidebar-container
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden" className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
ref={containerRef}> ref={containerRef}>
<Helmet> <Helmet>
@@ -142,24 +89,7 @@ const PostListPage: FC = () => {
}}/> }}/>
<MainArea> <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="広場"> <Tab name="広場">
{posts.length > 0 {posts.length > 0
? ( ? (
+9 -19
ファイルの表示
@@ -30,12 +30,16 @@ describe ('PostNewPage', () => {
expect (screen.getByText ('403')).toBeInTheDocument () expect (screen.getByText ('403')).toBeInTheDocument ()
}) })
it ('submits a new post with manual title and thumbnail fetch UI', async () => { it ('submits a new post with manual title and thumbnail settings', async () => {
api.apiPost.mockResolvedValueOnce ({}) api.apiPost.mockResolvedValueOnce ({})
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
@@ -58,24 +62,6 @@ describe ('PostNewPage', () => {
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
}) })
it ('preserves duration while the video tag is temporarily removed', () => {
api.apiGet.mockResolvedValue ([])
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const tags = screen.getAllByRole ('textbox')[3]
fireEvent.change (tags, { target: { value: '動画' } })
fireEvent.change (screen.getByRole ('spinbutton'), { target: { value: '180.5' } })
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByRole ('spinbutton')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByRole ('spinbutton')).toHaveValue (180.5)
})
it ('shows 422 validation errors for post fields', async () => { it ('shows 422 validation errors for post fields', async () => {
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (true) api.isApiError.mockReturnValue (true)
@@ -93,6 +79,10 @@ describe ('PostNewPage', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>) renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
fireEvent.click (checkboxes[0])
fireEvent.click (checkboxes[1])
const textboxes = screen.getAllByRole ('textbox') const textboxes = screen.getAllByRole ('textbox')
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } }) fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } }) fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
+53 -63
ファイルの表示
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useState, useRef } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@@ -25,7 +25,7 @@ import type { User } from '@/types'
type Props = { user: User | null } type Props = { user: User | null }
type PostFormField = type PostFormField =
'url' | 'title' | 'tags' | 'parentPostIds' | 'videoMs' | 'originalCreatedAt' | 'thumbnail' 'url' | 'title' | 'tags' | 'parentPostIds' | 'originalCreatedAt' | 'thumbnail'
const PostNewPage: FC<Props> = ({ user }) => { const PostNewPage: FC<Props> = ({ user }) => {
@@ -40,18 +40,17 @@ const PostNewPage: FC<Props> = ({ user }) => {
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [parentPostIds, setParentPostIds] = useState ('') const [parentPostIds, setParentPostIds] = useState ('')
const [tags, setTags] = useState ('') const [tags, setTags] = useState ('')
const [duration, setDuration] = useState ('') const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true)
const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) const [thumbnailFile, setThumbnailFile] = useState<File | null> (null)
const [thumbnailLoading, setThumbnailLoading] = useState (false) const [thumbnailLoading, setThumbnailLoading] = useState (false)
const [thumbnailPreview, setThumbnailPreview] = useState<string> ('') const [thumbnailPreview, setThumbnailPreview] = useState<string> ('')
const [title, setTitle] = useState ('') const [title, setTitle] = useState ('')
const [titleAutoFlg, setTitleAutoFlg] = useState (true)
const [titleLoading, setTitleLoading] = useState (false) const [titleLoading, setTitleLoading] = useState (false)
const [url, setURL] = useState ('') const [url, setURL] = useState ('')
const previousURLRef = useRef ('')
const thumbnailPreviewRef = useRef ('') const thumbnailPreviewRef = useRef ('')
const videoFlg =
useMemo (() => tags.split (/\s+/).some (tag => tag.replace (/\[.*\]$/, '') === '動画'),
[tags])
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors () clearValidationErrors ()
@@ -61,8 +60,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
formData.append ('url', url) formData.append ('url', url)
formData.append ('tags', tags) formData.append ('tags', tags)
formData.append ('parent_post_ids', parentPostIds) formData.append ('parent_post_ids', parentPostIds)
if (videoFlg && duration !== '')
formData.append ('duration', duration)
if (thumbnailFile) if (thumbnailFile)
formData.append ('thumbnail', thumbnailFile) formData.append ('thumbnail', thumbnailFile)
if (originalCreatedFrom) if (originalCreatedFrom)
@@ -83,17 +80,23 @@ 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 () => { const fetchTitle = useCallback (async () => {
setTitle ('')
setTitleLoading (true) setTitleLoading (true)
try
{
const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } })
setTitle (data.title || '') setTitle (data.title || '')
}
finally
{
setTitleLoading (false) setTitleLoading (false)
}
}, [url]) }, [url])
const fetchThumbnail = useCallback (async () => { const fetchThumbnail = useCallback (async () => {
@@ -102,8 +105,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
setThumbnailLoading (true) setThumbnailLoading (true)
if (thumbnailPreviewRef.current) if (thumbnailPreviewRef.current)
URL.revokeObjectURL (thumbnailPreviewRef.current) URL.revokeObjectURL (thumbnailPreviewRef.current)
try
{
const data = await apiGet<Blob> ('/preview/thumbnail', const data = await apiGet<Blob> ('/preview/thumbnail',
{ params: { url }, responseType: 'blob' }) { params: { url }, responseType: 'blob' })
const imageURL = URL.createObjectURL (data) const imageURL = URL.createObjectURL (data)
@@ -111,17 +112,23 @@ const PostNewPage: FC<Props> = ({ user }) => {
setThumbnailFile (new File ([data], setThumbnailFile (new File ([data],
'thumbnail.png', 'thumbnail.png',
{ type: data.type || 'image/png' })) { type: data.type || 'image/png' }))
}
finally
{
setThumbnailLoading (false) setThumbnailLoading (false)
}
}, [url]) }, [url])
useEffect (() => { useEffect (() => {
thumbnailPreviewRef.current = thumbnailPreview thumbnailPreviewRef.current = thumbnailPreview
}, [thumbnailPreview]) }, [thumbnailPreview])
useEffect (() => {
if (titleAutoFlg && url)
fetchTitle ()
}, [fetchTitle, titleAutoFlg, url])
useEffect (() => {
if (thumbnailAutoFlg && url)
fetchThumbnail ()
}, [fetchThumbnail, thumbnailAutoFlg, url])
if (!(editable)) if (!(editable))
return <Forbidden/> return <Forbidden/>
@@ -143,13 +150,19 @@ const PostNewPage: FC<Props> = ({ user }) => {
onChange={e => setURL (e.target.value)} onChange={e => setURL (e.target.value)}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
className={inputClass (invalid)}/>)} className={inputClass (invalid)}
onBlur={handleURLBlur}/>)}
</FormField> </FormField>
{/* タイトル */} {/* タイトル */}
<FormField label="タイトル" messages={fieldErrors.title}> <FormField
checkBox={{
label: '自動',
checked: titleAutoFlg,
onChange: ev => setTitleAutoFlg (ev.target.checked)}}
label="タイトル"
messages={fieldErrors.title}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<div className="space-y-2">
<input type="text" <input type="text"
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
@@ -157,36 +170,27 @@ const PostNewPage: FC<Props> = ({ user }) => {
value={title} value={title}
placeholder={titleLoading ? 'Loading...' : ''} placeholder={titleLoading ? 'Loading...' : ''}
onChange={ev => setTitle (ev.target.value)} onChange={ev => setTitle (ev.target.value)}
disabled={titleLoading}/> disabled={titleAutoFlg}/>)}
<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>
{/* サムネール */} {/* サムネール */}
<FormField label="サムネール" messages={fieldErrors.thumbnail}> <FormField
checkBox={{
label: '自動',
checked: thumbnailAutoFlg,
onChange: ev => setThumbnailAutoFlg (ev.target.checked)}}
label="サムネール"
messages={fieldErrors.thumbnail}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm"> {thumbnailAutoFlg
<span> URL </span> ? (thumbnailLoading
<Button ? <p className="text-gray-500 text-sm">Loading...</p>
type="button" : !(thumbnailPreview) && (
variant="outline" <p className="text-gray-500 text-sm">
onClick={() => void fetchThumbnail ()} URL
disabled={!(url) || thumbnailLoading}> </p>))
: (
</Button>
</div>
{thumbnailLoading && (
<p className="text-gray-500 text-sm">Loading...</p>)}
<input type="file" <input type="file"
accept="image/*" accept="image/*"
aria-describedby={describedBy} aria-describedby={describedBy}
@@ -198,7 +202,7 @@ const PostNewPage: FC<Props> = ({ user }) => {
setThumbnailFile (file) setThumbnailFile (file)
setThumbnailPreview (URL.createObjectURL (file)) setThumbnailPreview (URL.createObjectURL (file))
} }
}}/> }}/>)}
{thumbnailPreview && ( {thumbnailPreview && (
<img src={thumbnailPreview} <img src={thumbnailPreview}
alt="preview" alt="preview"
@@ -229,20 +233,6 @@ const PostNewPage: FC<Props> = ({ user }) => {
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}
errors={fieldErrors.originalCreatedAt}/> errors={fieldErrors.originalCreatedAt}/>
{/* 動画時間 */}
{(videoFlg &&
<FormField label="動画時間" messages={fieldErrors.videoMs}>
{({ invalid }) => (
<input
type="number"
min="0.001"
step="0.001"
value={duration}
onChange={e => setDuration (e.target.value)}
aria-invalid={invalid}
className={inputClass (invalid)}/>)}
</FormField>)}
{/* 送信 */} {/* 送信 */}
<Button onClick={handleSubmit} <Button onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
-40
ファイルの表示
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostSearchPage from '@/pages/posts/PostSearchPage' import PostSearchPage from '@/pages/posts/PostSearchPage'
import { buildPost, buildTag } from '@/test/factories' import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const postsApi = vi.hoisted (() => ({ const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (), fetchPosts: vi.fn (),
@@ -19,7 +18,6 @@ vi.mock ('@/lib/api', () => api)
describe ('PostSearchPage', () => { describe ('PostSearchPage', () => {
beforeEach (() => { beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks () vi.clearAllMocks ()
api.apiGet.mockResolvedValue ([]) api.apiGet.mockResolvedValue ([])
}) })
@@ -42,7 +40,6 @@ describe ('PostSearchPage', () => {
tags: 'x', tags: 'x',
match: 'any', match: 'any',
page: 2, page: 2,
limit: 20,
}), }),
) )
}) })
@@ -84,41 +81,4 @@ describe ('PostSearchPage', () => {
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
}) })
it ('uses URL limit before localStorage and stores the active list settings', async () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { postSearch: { limit: 100, order: 'title:asc' } } }))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (
<PostSearchPage/>,
{ route: '/posts/search?limit=50&order=created_at%3Adesc' },
)
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({ limit: 50, order: 'created_at:desc' }),
)
})
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'created_at:desc' } } })
})
it ('moves the compact limit control outside the search form and resets page on change', async () => {
postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
renderWithProviders (<PostSearchPage/>, { route: '/posts/search?page=4' })
const limitSelect = await screen.findByLabelText ('件数')
expect (limitSelect.closest ('form')).toBeNull ()
fireEvent.change (limitSelect, { target: { value: '100' } })
await waitFor (() => {
expect (postsApi.fetchPosts).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
}) })
+11 -122
ファイルの表示
@@ -14,20 +14,8 @@ import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput' import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_POST_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -43,40 +31,8 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
qs.set (k, t) 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 PostSearchPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const thumbnailTransition = clientAnimationTransition (
animationMode,
{ normal: { type: 'spring', stiffness: 500, damping: 40, mass: .5 },
reduced: { duration: .08, ease: 'linear' as const } },
)
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -85,11 +41,7 @@ const PostSearchPage: FC = () => {
[location.search]) [location.search])
const page = Number (query.get ('page') ?? 1) const page = Number (query.get ('page') ?? 1)
const limit = ( const limit = Number (query.get ('limit') ?? 20)
parseLimit (query.get ('limit'))
?? getClientListLimit ('postSearch')
?? DEFAULT_POST_LIST_LIMIT
)
const qURL = query.get ('url') ?? '' const qURL = query.get ('url') ?? ''
const qTitle = query.get ('title') ?? '' const qTitle = query.get ('title') ?? ''
@@ -101,11 +53,7 @@ const PostSearchPage: FC = () => {
const qCreatedTo = query.get ('created_to') ?? '' const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? '' const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') ?? '' const qUpdatedTo = query.get ('updated_to') ?? ''
const order = ( const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
parseOrder (query.get ('order'))
?? getClientListOrder<FetchPostsOrder> ('postSearch')
?? DEFAULT_POST_LIST_ORDER
)
const [createdFrom, setCreatedFrom] = useState<string | null> (null) const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null) const [createdTo, setCreatedTo] = useState<string | null> (null)
@@ -135,10 +83,6 @@ const PostSearchPage: FC = () => {
const results = data?.posts ?? [] const results = data?.posts ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('postSearch', { limit, order })
}, [limit, order])
useEffect (() => { useEffect (() => {
setURL (qURL ?? '') setURL (qURL ?? '')
setTitle (qTitle ?? '') setTitle (qTitle ?? '')
@@ -151,11 +95,9 @@ const PostSearchPage: FC = () => {
setUpdatedFrom (qUpdatedFrom) setUpdatedFrom (qUpdatedFrom)
setUpdatedTo (qUpdatedTo) setUpdatedTo (qUpdatedTo)
document.querySelector ('table')?.scrollIntoView ({ document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
behavior: clientScrollBehaviour (animationMode) }) }, [location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedFrom,
}, [animationMode, location.search, qCreatedFrom, qCreatedTo, qMatch, qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom, qUpdatedTo, qURL])
qOriginalCreatedFrom, qOriginalCreatedTo, qTags, qTitle, qUpdatedFrom,
qUpdatedTo, qURL])
const search = async () => { const search = async () => {
const qs = new URLSearchParams () const qs = new URLSearchParams ()
@@ -171,22 +113,6 @@ const PostSearchPage: FC = () => {
qs.set ('match', matchType) qs.set ('match', matchType)
qs.set ('page', '1') qs.set ('page', '1')
qs.set ('order', order) 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 () }`) navigate (`${ location.pathname }?${ qs.toString () }`)
} }
@@ -195,23 +121,6 @@ const PostSearchPage: FC = () => {
search () 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', const defaultDirection = { title: 'asc',
url: 'asc', url: 'asc',
original_created_at: 'desc', original_created_at: 'desc',
@@ -225,24 +134,7 @@ const PostSearchPage: FC = () => {
</Helmet> </Helmet>
<div className="max-w-xl"> <div className="max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle> <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"> <form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */} {/* タイトル */}
@@ -250,7 +142,6 @@ const PostSearchPage: FC = () => {
{({ invalid }) => ( {({ invalid }) => (
<input <input
type="text" type="text"
data-shortcut-focus-search="true"
value={title} value={title}
onChange={e => setTitle (e.target.value)} onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
@@ -270,7 +161,6 @@ const PostSearchPage: FC = () => {
<FormField label="タグ"> <FormField label="タグ">
{() => ( {() => (
<TagInput <TagInput
includeNico={true}
value={tagsStr} value={tagsStr}
setValue={setTagsStr}/>)} setValue={setTagsStr}/>)}
</FormField> </FormField>
@@ -406,16 +296,15 @@ const PostSearchPage: FC = () => {
</thead> </thead>
<tbody> <tbody>
{results.map (row => ( {results.map (row => (
<tr <tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
key={row.id}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2"> <td className="p-2">
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}> <PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
<motion.div <motion.div
layoutId={animationMode === 'off' layoutId={`page-${ row.id }`}
? undefined transition={{ type: 'spring',
: `page-${ row.id }`} stiffness: 500,
transition={thumbnailTransition}> damping: 40,
mass: .5 }}>
<img src={row.thumbnail || row.thumbnailBase || undefined} <img src={row.thumbnail || row.thumbnailBase || undefined}
alt={row.title || row.url} alt={row.title || row.url}
title={row.title || row.url || undefined} title={row.title || row.url || undefined}
-2
ファイルの表示
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories' import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
import type { NicoTag } from '@/types' import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
@@ -28,7 +27,6 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue, useDialogue: () => dialogue,
})) }))
+2 -7
ファイルの表示
@@ -17,10 +17,8 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { extractValidationError } from '@/lib/apiErrors' import { extractValidationError } from '@/lib/apiErrors'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys' import { tagsKeys } from '@/lib/queryKeys'
import { fetchNicoTags } from '@/lib/tags' import { fetchNicoTags } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString, inputClass } from '@/lib/utils' import { cn, dateString, inputClass } from '@/lib/utils'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
@@ -40,8 +38,6 @@ const setIf = (qs: URLSearchParams, key: string, value: string) => {
const NicoTagListPage: FC<Props> = ({ user }) => { const NicoTagListPage: FC<Props> = ({ user }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const dialogue = useDialogue () const dialogue = useDialogue ()
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -156,9 +152,8 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
setLinkStatus (qLinkStatus) setLinkStatus (qLinkStatus)
setEditingId (null) setEditingId (null)
document.querySelector ('table')?.scrollIntoView ({ document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
behavior: clientScrollBehaviour (animationMode) }) }, [location.search, qLinkedTag, qLinkStatus, qName])
}, [animationMode, location.search, qLinkedTag, qLinkStatus, qName])
useEffect (() => { useEffect (() => {
if (!(data)) if (!(data))
+2 -7
ファイルの表示
@@ -11,10 +11,8 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORY_NAMES } from '@/consts' import { CATEGORY_NAMES } from '@/consts'
import { apiPut } from '@/lib/api' import { apiPut } from '@/lib/api'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { fetchTagChanges } from '@/lib/tags' import { fetchTagChanges } from '@/lib/tags'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn, dateString } from '@/lib/utils' import { cn, dateString } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -45,8 +43,6 @@ const renderStateDiff = (diff: { current: string | null; prev: string | null })
const TagHistoryPage: FC = () => { const TagHistoryPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const id = query.get ('id') const id = query.get ('id')
@@ -62,9 +58,8 @@ const TagHistoryPage: FC = () => {
const qc = useQueryClient () const qc = useQueryClient ()
useEffect (() => { useEffect (() => {
document.querySelector ('table')?.scrollIntoView ({ document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
behavior: clientScrollBehaviour (animationMode) }) }, [location.search])
}, [animationMode, location.search])
return ( return (
<MainArea> <MainArea>
+3 -48
ファイルの表示
@@ -1,10 +1,9 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import TagListPage from '@/pages/tags/TagListPage' import TagListPage from '@/pages/tags/TagListPage'
import { buildTag } from '@/test/factories' import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings'
const tagsApi = vi.hoisted (() => ({ const tagsApi = vi.hoisted (() => ({
fetchTags: vi.fn (), fetchTags: vi.fn (),
@@ -13,11 +12,6 @@ const tagsApi = vi.hoisted (() => ({
vi.mock ('@/lib/tags', () => tagsApi) vi.mock ('@/lib/tags', () => tagsApi)
describe ('TagListPage', () => { describe ('TagListPage', () => {
beforeEach (() => {
localStorage.clear ()
vi.clearAllMocks ()
})
it ('loads tags from URL filters and renders the results table', async () => { it ('loads tags from URL filters and renders the results table', async () => {
tagsApi.fetchTags.mockResolvedValueOnce ({ tagsApi.fetchTags.mockResolvedValueOnce ({
tags: [buildTag ({ tags: [buildTag ({
@@ -44,8 +38,6 @@ describe ('TagListPage', () => {
name: '虹', name: '虹',
category: 'character', category: 'character',
page: 3, page: 3,
limit: 20,
order: 'post_count:desc',
postCountGTE: 5, postCountGTE: 5,
deprecated: true, deprecated: true,
}), }),
@@ -56,7 +48,7 @@ describe ('TagListPage', () => {
'/tags/7', '/tags/7',
) )
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0) expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
expect (screen.getAllByRole ('combobox')[2]).toHaveValue ('1') expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1')
expect (screen.getAllByText ('廃止')).toHaveLength (2) expect (screen.getAllByText ('廃止')).toHaveLength (2)
}) })
@@ -66,7 +58,7 @@ describe ('TagListPage', () => {
renderWithProviders (<TagListPage/>, { route: '/tags' }) renderWithProviders (<TagListPage/>, { route: '/tags' })
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } }) fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
fireEvent.change (screen.getAllByRole ('combobox')[1], { fireEvent.change (screen.getAllByRole ('combobox')[0], {
target: { value: 'character' }, target: { value: 'character' },
}) })
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!) fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
@@ -90,41 +82,4 @@ describe ('TagListPage', () => {
expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument ()
}) })
it ('uses URL limit before localStorage and preserves the query order', async () => {
localStorage.setItem (
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify ({ lists: { tagList: { limit: 100, order: 'name:asc' } } }))
tagsApi.fetchTags.mockResolvedValueOnce ({ tags: [], count: 0 })
renderWithProviders (
<TagListPage/>,
{ route: '/tags?limit=50&order=updated_at%3Adesc' },
)
await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenCalledWith (
expect.objectContaining ({ limit: 50, order: 'updated_at:desc' }),
)
})
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
.toMatchObject ({ lists: { tagList: { limit: 50, order: 'updated_at:desc' } } })
})
it ('keeps the compact limit control outside the search form and resets page on change', async () => {
tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 })
renderWithProviders (<TagListPage/>, { route: '/tags?page=4' })
const limitSelect = await screen.findByLabelText ('件数')
expect (limitSelect.closest ('form')).toBeNull ()
fireEvent.change (limitSelect, { target: { value: '100' } })
await waitFor (() => {
expect (tagsApi.fetchTags).toHaveBeenLastCalledWith (
expect.objectContaining ({ page: 1, limit: 100 }),
)
})
})
}) })
+5 -108
ファイルの表示
@@ -13,18 +13,7 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { clientScrollBehaviour } from '@/lib/clientAnimation'
import { tagsKeys } from '@/lib/queryKeys' import { tagsKeys } from '@/lib/queryKeys'
import {
DEFAULT_POST_LIST_LIMIT,
DEFAULT_TAG_LIST_ORDER,
getClientListLimit,
getClientListOrder,
LIST_LIMIT_OPTIONS,
setClientListSettings,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchTags } from '@/lib/tags' import { fetchTags } from '@/lib/tags'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
@@ -46,35 +35,8 @@ const boolFromQuery = (value: string | null): boolean =>
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : '' 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 TagListPage: FC = () => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -82,11 +44,7 @@ const TagListPage: FC = () => {
const query = useMemo (() => new URLSearchParams (location.search), [location.search]) const query = useMemo (() => new URLSearchParams (location.search), [location.search])
const page = Number (query.get ('page') ?? 1) const page = Number (query.get ('page') ?? 1)
const limit = ( const limit = Number (query.get ('limit') ?? 20)
parseLimit (query.get ('limit'))
?? getClientListLimit ('tagList')
?? DEFAULT_POST_LIST_LIMIT
)
const qName = query.get ('name') ?? '' const qName = query.get ('name') ?? ''
const qCategory = (query.get ('category') || null) as Category | null const qCategory = (query.get ('category') || null) as Category | null
@@ -100,11 +58,7 @@ const TagListPage: FC = () => {
const qDeprecated = query.has ('deprecated') const qDeprecated = query.has ('deprecated')
? boolFromQuery (query.get ('deprecated')) ? boolFromQuery (query.get ('deprecated'))
: null : null
const order = ( const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
parseOrder (query.get ('order'))
?? getClientListOrder<FetchTagsOrder> ('tagList')
?? DEFAULT_TAG_LIST_ORDER
)
const [name, setName] = useState ('') const [name, setName] = useState ('')
const [category, setCategory] = useState<Category | null> (null) const [category, setCategory] = useState<Category | null> (null)
@@ -134,10 +88,6 @@ const TagListPage: FC = () => {
const results = data?.tags ?? [] const results = data?.tags ?? []
const totalPages = data ? Math.ceil (data.count / limit) : 0 const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
setClientListSettings ('tagList', { limit, order })
}, [limit, order])
useEffect (() => { useEffect (() => {
setName (qName) setName (qName)
setCategory (qCategory) setCategory (qCategory)
@@ -149,11 +99,9 @@ const TagListPage: FC = () => {
setUpdatedTo (qUpdatedTo) setUpdatedTo (qUpdatedTo)
setDeprecated (qDeprecated) setDeprecated (qDeprecated)
document.querySelector ('table')?.scrollIntoView ({ document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
behavior: clientScrollBehaviour (animationMode) }) }, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
}, [animationMode, location.search, qCategory, qCreatedFrom, qCreatedTo, qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated])
qName, qPostCountGTE, qPostCountLTE, qUpdatedFrom, qUpdatedTo,
qDeprecated])
const handleSearch = (e: FormEvent) => { const handleSearch = (e: FormEvent) => {
e.preventDefault () e.preventDefault ()
@@ -173,49 +121,16 @@ const TagListPage: FC = () => {
qs.set ('deprecated', deprecated ? '1' : '0') qs.set ('deprecated', deprecated ? '1' : '0')
qs.set ('page', '1') qs.set ('page', '1')
qs.set ('order', order) qs.set ('order', order)
qs.set ('limit', String (limit))
navigate (`${ location.pathname }?${ qs.toString () }`) 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', const defaultDirection = { name: 'asc',
category: 'asc', category: 'asc',
post_count: 'desc', post_count: 'desc',
created_at: 'desc', created_at: 'desc',
updated_at: 'desc' } as const 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 ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
@@ -223,24 +138,7 @@ const TagListPage: FC = () => {
</Helmet> </Helmet>
<div className="max-w-xl"> <div className="max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle></PageTitle> <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"> <form onSubmit={handleSearch} className="space-y-2">
{/* 名前 */} {/* 名前 */}
@@ -248,7 +146,6 @@ const TagListPage: FC = () => {
{({ invalid }) => ( {({ invalid }) => (
<input <input
type="text" type="text"
data-shortcut-focus-search="true"
value={name} value={name}
onChange={e => setName (e.target.value)} onChange={e => setName (e.target.value)}
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
+17 -81
ファイルの表示
@@ -1,10 +1,9 @@
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { buildPost, import { buildPost,
buildTag,
buildTheatre, buildTheatre,
buildTheatreComment, buildTheatreComment,
buildTheatreInfo, buildTheatreInfo,
@@ -41,7 +40,6 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({ vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue, useDialogue: () => dialogue,
})) }))
vi.mock ('@/components/PostEmbed', () => ({ vi.mock ('@/components/PostEmbed', () => ({
@@ -89,10 +87,6 @@ const weights = buildTheatrePostSelectionWeights ({
tags: [], tags: [],
}], }],
}) })
const watchingUsers = [
{ id: 1, name: 'tester' },
{ id: 2, name: 'another' },
]
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) => const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
renderWithProviders ( renderWithProviders (
@@ -102,24 +96,7 @@ const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
{ route: '/theatres/7' }, { route: '/theatres/7' },
) )
const tagSection = (): HTMLElement =>
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
const mockDefaultApi = () => { const mockDefaultApi = () => {
let commentsFetched = false
let currentTheatreInfo = buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers,
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: watchingUsers.length,
voted: false,
},
})
api.apiGet.mockImplementation ((path: string) => { api.apiGet.mockImplementation ((path: string) => {
switch (path) switch (path)
{ {
@@ -127,10 +104,6 @@ const mockDefaultApi = () => {
return Promise.resolve (theatre) return Promise.resolve (theatre)
case '/theatres/7/comments': case '/theatres/7/comments':
if (commentsFetched)
return Promise.resolve ([])
commentsFetched = true
return Promise.resolve ([ return Promise.resolve ([
buildTheatreComment ({ buildTheatreComment ({
theatreId: 7, theatreId: 7,
@@ -155,22 +128,32 @@ const mockDefaultApi = () => {
switch (path) switch (path)
{ {
case '/theatres/7/watching': case '/theatres/7/watching':
return Promise.resolve (currentTheatreInfo) return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
case '/theatres/7/skip_vote': case '/theatres/7/skip_vote':
currentTheatreInfo = buildTheatreInfo ({ return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id, postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z', postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000, postElapsedMs: 2_000,
watchingUsers, watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: { skipVote: {
votesCount: 1, votesCount: 1,
requiredCount: 2, requiredCount: 2,
watchingUsersCount: watchingUsers.length, watchingUsersCount: 1,
voted: true, voted: true,
}, },
}) }))
return Promise.resolve (currentTheatreInfo)
default: default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`)) return Promise.reject (new Error (`Unexpected PUT ${ path }`))
@@ -262,53 +245,6 @@ describe ('TheatreDetailPage', () => {
expect (postEmbed.seek).not.toHaveBeenCalledWith (0) expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
}) })
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
const parentTag = buildTag ({
id: 11,
name: '親タグ',
category: 'general',
children: [childTag],
})
const duplicateParentTag = buildTag ({
id: 13,
name: '別親タグ',
category: 'general',
children: [childTag],
})
postsApi.fetchPost.mockResolvedValueOnce (buildPost ({
...currentPost,
tags: [parentTag, duplicateParentTag],
}))
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })[0])
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByText ('↳').length).toBeGreaterThan (0)
fireEvent.click (screen.getByRole ('button', { name: '2 列 A 型' }))
fireEvent.click (screen.getAllByRole ('button', { name: '横並び' })[0])
await waitFor (() => {
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getByRole ('link', { name: '子タグ' }))
.toBeInTheDocument ()
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
.toHaveLength (1)
})
})
it ('does not advance host post while video length is unknown', async () => { it ('does not advance host post while video length is unknown', async () => {
api.apiPut.mockImplementation ((path: string) => { api.apiPut.mockImplementation ((path: string) => {
switch (path) switch (path)
+54 -148
ファイルの表示
@@ -10,21 +10,11 @@ import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/components/dialogues/DialogueProvider'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPost } from '@/lib/posts' import { fetchPost } from '@/lib/posts'
import {
getClientTheatreLayoutMode,
getClientTheatreTagFlow,
setClientTheatreLayoutMode,
setClientTheatreTagFlow,
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils' import { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -60,8 +50,8 @@ const INITIAL_THEATRE_INFO: TheatreInfo =
const INITIAL_WEIGHTS: TheatrePostSelectionWeights = const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] } { tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
const MIN_MAIN_WIDTH = 360 const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const SIDEBAR_GAP_WIDTH = 16 const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = { const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列', threeColumns: '3 列',
@@ -112,28 +102,6 @@ const compareTagName = (a: Tag, b: Tag): number =>
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1) a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
const flattenTags = (tags: Tag[]): Tag[] => {
const flattened: Tag[] = []
const seen = new Set<number> ()
const visit = (tag: Tag) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
flattened.push (tag)
for (const child of tag.children ?? [])
visit (child)
}
for (const tag of tags)
visit (tag)
return flattened
}
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => { const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
const grouped: Partial<Record<Category, Tag[]>> = { } const grouped: Partial<Record<Category, Tag[]>> = { }
@@ -150,39 +118,15 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
} }
const renderReadonlyTagTree = (
tag: Tag,
nestLevel: number,
path: string,
ancestors: Set<number> = new Set<number> (),
): ReactNode[] => {
const key = `${ path }-${ tag.id }`
const nextAncestors = new Set (ancestors)
nextAncestors.add (tag.id)
return [
(
<li key={key} className="text-left leading-tight">
<TagLink tag={tag} nestLevel={nestLevel} withCount={false}/>
</li>),
...((tag.children ?? [])
.filter (child => !(nextAncestors.has (child.id)))
.sort (compareTagName)
.flatMap (child =>
renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))]
}
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{ tags, compact, flow = 'vertical' }) => { { tags, compact, flow = 'vertical' }) => {
const horizontalGrouped = tagsByCategory (flattenTags (tags)) const grouped = tagsByCategory (tags)
const verticalGrouped = tagsByCategory (tags)
if (flow === 'horizontal') if (flow === 'horizontal')
{ {
return ( return (
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}> <ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
{CATEGORIES.flatMap (cat => horizontalGrouped[cat] ?? []).map (tag => ( {CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
<li key={tag.id} className="text-left leading-tight"> <li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/> <TagLink tag={tag} withCount={false}/>
</li>))} </li>))}
@@ -192,7 +136,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{CATEGORIES.map (cat => { {CATEGORIES.map (cat => {
const rows = verticalGrouped[cat] ?? [] const rows = grouped[cat] ?? []
if (rows.length === 0) if (rows.length === 0)
return null return null
@@ -202,8 +146,10 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{CATEGORY_NAMES[cat]} {CATEGORY_NAMES[cat]}
</div> </div>
<ul className={cn ('space-y-1', compact && 'text-sm')}> <ul className={cn ('space-y-1', compact && 'text-sm')}>
{rows.flatMap (tag => {rows.map (tag => (
renderReadonlyTagTree (tag, 0, `cat-${ cat }`))} <li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/>
</li>))}
</ul> </ul>
</div>) </div>)
})} })}
@@ -215,28 +161,17 @@ type Props = { user: User | null }
const TheatreDetailPage: FC<Props> = ({ user }: Props) => { const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const { id } = useParams () const { id } = useParams ()
const dialogue = useDialogue () const dialogue = useDialogue ()
const commentsRef = useRef<HTMLDivElement> (null) const commentsRef = useRef<HTMLDivElement> (null)
const embedRef = useRef<NiconicoViewerHandle> (null) const embedRef = useRef<NiconicoViewerHandle> (null)
const loadingRef = useRef (false) const loadingRef = useRef (false)
const sidebarContainerRef = useRef<HTMLDivElement> (null)
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
const theatreInfoReceivedAtRef = useRef (performance.now ()) const theatreInfoReceivedAtRef = useRef (performance.now ())
const videoLengthRef = useRef (0) const videoLengthRef = useRef (0)
const lastCommentNoRef = useRef (0) const lastCommentNoRef = useRef (0)
const [leftSidebarWidth, setLeftSidebarWidth] = useState (256)
const [rightSidebarWidth, setRightSidebarWidth] = useState (256)
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (
() => typeof window === 'undefined' ? 0 : window.innerWidth)
const [comments, setComments] = useState<TheatreComment[]> ([]) const [comments, setComments] = useState<TheatreComment[]> ([])
const [content, setContent] = useState ('') const [content, setContent] = useState ('')
const [editingPost, setEditingPost] = useState<Post | null> (null) const [editingPost, setEditingPost] = useState<Post | null> (null)
@@ -249,25 +184,36 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const [post, setPost] = useState<Post | null> (null) const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0) const [videoLength, setVideoLength] = useState (0)
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS) const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
const [layoutMode, setLayoutMode] = const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => {
useState<TheatreLayoutMode> (() => getClientTheatreLayoutMode ()) const stored = localStorage.getItem (LAYOUT_STORAGE_KEY)
const [tagFlow, setTagFlow] = useState<TagFlow> (() => getClientTheatreTagFlow ()) 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 { fieldErrors, clearValidationErrors, applyValidationError } = const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> () useValidationErrors<TheatreCommentField> ()
const changeLayoutMode = (mode: TheatreLayoutMode) => { const changeLayoutMode = (mode: TheatreLayoutMode) => {
setLayoutMode (mode) setLayoutMode (mode)
setClientTheatreLayoutMode (mode) localStorage.setItem (LAYOUT_STORAGE_KEY, mode)
} }
const changeTagFlow = (flow: TagFlow) => { const changeTagFlow = (flow: TagFlow) => {
setTagFlow (flow) setTagFlow (flow)
setClientTheatreTagFlow (flow) localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow)
} }
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
theatreInfoReceivedAtRef.current = performance.now () theatreInfoReceivedAtRef.current = performance.now ()
theatreInfoRef.current = nextInfo
setTheatreInfo (nextInfo) setTheatreInfo (nextInfo)
}, []) }, [])
@@ -334,17 +280,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
lastCommentNoRef.current = comments[0]?.no ?? 0 lastCommentNoRef.current = comments[0]?.no ?? 0
}, [comments]) }, [comments])
useEffect (() => {
const updateSidebarContainerWidth = () => {
const nextWidth = sidebarContainerRef.current?.clientWidth ?? window.innerWidth
setSidebarContainerWidth (nextWidth)
}
updateSidebarContainerWidth ()
window.addEventListener ('resize', updateSidebarContainerWidth)
return () => window.removeEventListener ('resize', updateSidebarContainerWidth)
}, [])
useEffect (() => { useEffect (() => {
if (!(id)) if (!(id))
return return
@@ -408,9 +343,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
setComments (prev => [...newComments, ...prev]) setComments (prev => [...newComments, ...prev])
} }
if (loadingRef.current)
return
const currentInfo = theatreInfoRef.current const currentInfo = theatreInfoRef.current
const ended = const ended =
currentInfo.hostFlg currentInfo.hostFlg
@@ -432,9 +364,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return return
} }
const watchingRequestedAt = performance.now ()
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`) const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current) if (!(cancelled))
applyTheatreInfo (nextInfo) applyTheatreInfo (nextInfo)
} }
catch (error) catch (error)
@@ -563,7 +494,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (!(id) || !(post)) if (!(id) || !(post))
return return
loadingRef.current = true
setLoading (true) setLoading (true)
try try
{ {
@@ -592,7 +522,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
} }
finally finally
{ {
loadingRef.current = false
setLoading (false) setLoading (false)
} }
} }
@@ -627,9 +556,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
clearValidationErrors () clearValidationErrors ()
await apiPost (`/theatres/${ id }/comments`, { content }) await apiPost (`/theatres/${ id }/comments`, { content })
setContent ('') setContent ('')
commentsRef.current?.scrollTo ({ commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' })
top: 0,
behavior: clientScrollBehaviour (animationMode) })
} }
catch (error) catch (error)
{ {
@@ -668,26 +595,6 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (status >= 400) if (status >= 400)
return <ErrorScreen status={status}/> return <ErrorScreen status={status}/>
const singleSidebarMaxWidth = Math.max (
192,
sidebarContainerWidth - MIN_MAIN_WIDTH - SIDEBAR_GAP_WIDTH)
const leftSidebarMaxWidth = layoutMode === 'threeColumns'
? Math.max (
192,
sidebarContainerWidth
- rightSidebarWidth
- MIN_MAIN_WIDTH
- SIDEBAR_GAP_WIDTH * 2)
: singleSidebarMaxWidth
const rightSidebarMaxWidth = layoutMode === 'threeColumns'
? Math.max (
192,
sidebarContainerWidth
- leftSidebarWidth
- MIN_MAIN_WIDTH
- SIDEBAR_GAP_WIDTH * 2)
: singleSidebarMaxWidth
const tagPanel = ( const tagPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800"> <section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
@@ -846,8 +753,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return ( return (
<motion.div <motion.div
layout={animationMode === 'off' ? false : 'position'} layout="position"
transition={{ layout: layoutTransition }} transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950 className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50"> md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
<Helmet> <Helmet>
@@ -855,26 +762,30 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>} {theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
</Helmet> </Helmet>
<div <div className={cn (
ref={sidebarContainerRef} 'grid min-h-full gap-4 overflow-visible md:h-full md:overflow-hidden',
data-sidebar-container (layoutMode === 'threeColumns'
className="flex min-h-full flex-col gap-4 overflow-visible md:h-full md:flex-row && ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]',
md:overflow-hidden"> 'xl:grid-cols-[18rem_minmax(0,1fr)_24rem]']),
(layoutMode === 'tagsBottom'
&& 'md:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]'),
(layoutMode === 'commentsBottom'
&& 'md:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]'))}>
{layoutMode !== 'tagsBottom' && ( {layoutMode !== 'tagsBottom' && (
<SidebarComponent <motion.aside
sidebarKey="theatre-tags" layout="position"
side="left" className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto
maxWidth={leftSidebarMaxWidth} md:[direction:rtl]">
onWidthChange={setLeftSidebarWidth} <div className="md:[direction:ltr]">
className="hidden md:block">
{tagPanel} {tagPanel}
</SidebarComponent>)} </div>
</motion.aside>)}
<motion.main <motion.main
layout={animationMode === 'off' ? false : 'position'} layout="position"
className="order-1 min-w-0 flex-1 space-y-4 md:order-none className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
md:min-w-[360px] md:overflow-y-auto"> layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
<div className="space-y-4"> <div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
<section className="overflow-hidden rounded border-zinc-300 <section className="overflow-hidden rounded border-zinc-300
dark:border-zinc-800"> dark:border-zinc-800">
<div className="flex flex-wrap items-center justify-between gap-3 <div className="flex flex-wrap items-center justify-between gap-3
@@ -1010,17 +921,12 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</motion.main> </motion.main>
{layoutMode !== 'commentsBottom' && ( {layoutMode !== 'commentsBottom' && (
<SidebarComponent <motion.aside
sidebarKey="theatre-comments" layout="position"
side="right" className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto">
maxWidth={rightSidebarMaxWidth}
onWidthChange={setRightSidebarWidth}
className="hidden md:block">
<div className="space-y-4">
{commentsPanel} {commentsPanel}
{participantsPanel} {participantsPanel}
</div> </motion.aside>)}
</SidebarComponent>)}
</div> </div>
</motion.div>) </motion.div>)
} }
+8 -22
ファイルの表示
@@ -6,7 +6,6 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPut: vi.fn (), apiPut: vi.fn (),
isApiError: vi.fn (), isApiError: vi.fn (),
})) }))
@@ -28,23 +27,12 @@ describe ('SettingPage', () => {
beforeEach (() => { beforeEach (() => {
vi.clearAllMocks () vi.clearAllMocks ()
api.isApiError.mockReturnValue (false) api.isApiError.mockReturnValue (false)
api.apiGet.mockImplementation ((path: string) => {
if (path === '/users/settings')
return Promise.resolve ({
theme: 'system',
auto_fetch_title: 'manual',
auto_fetch_thumbnail: 'manual',
wiki_editor_mode: 'split' })
if (path === '/users/theme_slots')
return Promise.resolve ([])
return Promise.resolve ({})
})
}) })
it ('shows loading when user is absent', () => { it ('shows loading when user is absent', () => {
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>) renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
expect (screen.getAllByText ('Loading...').length).toBeGreaterThan (0) expect (screen.getByText ('Loading...')).toBeInTheDocument ()
}) })
it ('updates the current user name', async () => { it ('updates the current user name', async () => {
@@ -54,9 +42,8 @@ describe ('SettingPage', () => {
renderWithProviders (<SettingPage user={user} setUser={setUser}/>) renderWithProviders (<SettingPage user={user} setUser={setUser}/>)
const nameInput = (await screen.findAllByRole ('textbox'))[0] fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'new' } })
fireEvent.change (nameInput, { target: { value: 'new' } }) fireEvent.click (screen.getByRole ('button', { name: '更新' }))
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
await waitFor (() => { await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith ( expect (api.apiPut).toHaveBeenCalledWith (
@@ -68,7 +55,7 @@ describe ('SettingPage', () => {
const formData = api.apiPut.mock.calls[0]?.[1] as FormData const formData = api.apiPut.mock.calls[0]?.[1] as FormData
expect (formData.get ('name')).toBe ('new') expect (formData.get ('name')).toBe ('new')
expect (setUser).toHaveBeenCalled () expect (setUser).toHaveBeenCalled ()
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '表示名を更新しました.' }) expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' })
}) })
it ('shows validation errors returned for the name field', async () => { it ('shows validation errors returned for the name field', async () => {
@@ -88,11 +75,10 @@ describe ('SettingPage', () => {
renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>) renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>)
const nameInput = (await screen.findAllByRole ('textbox'))[0] fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } })
fireEvent.change (nameInput, { target: { value: '' } }) fireEvent.click (screen.getByRole ('button', { name: '更新' }))
fireEvent.click (screen.getAllByRole ('button', { name: '表示名を更新' })[0])
expect ((await screen.findAllByText ('名前は必須です.')).length).toBeGreaterThan (0) expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument ()
expect (nameInput).toHaveAttribute ('aria-invalid', 'true') expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true')
}) })
}) })
ファイル差分が大きすぎるため省略します 差分を読込み
-6
ファイルの表示
@@ -24,11 +24,6 @@ import type { FC } from 'react'
import type { User, WikiPage } from '@/types' import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt 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 } type Props = { user: User | null }
@@ -119,7 +114,6 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{() => ( {() => (
<MdEditor value={body} <MdEditor value={body}
style={{ height: '500px' }} style={{ height: '500px' }}
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
renderHTML={text => mdParser.render (text)} renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)} onChange={({ text }) => setBody (text)}/>)}
</FormField> </FormField>
-6
ファイルの表示
@@ -22,11 +22,6 @@ import 'react-markdown-editor-lite/lib/index.css'
import type { User, WikiPage } from '@/types' import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt 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 } type Props = { user: User | null }
@@ -97,7 +92,6 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{() => ( {() => (
<MdEditor value={body} <MdEditor value={body}
style={{ height: '500px' }} style={{ height: '500px' }}
config={{ view: FIXED_WIKI_EDITOR_VIEW }}
renderHTML={text => mdParser.render (text)} renderHTML={text => mdParser.render (text)}
onChange={({ text }) => setBody (text)}/>)} onChange={({ text }) => setBody (text)}/>)}
</FormField> </FormField>

変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示